From bcf9613481583b05699aeca2003ff5ce69686880 Mon Sep 17 00:00:00 2001 From: Moss Doerksen Date: Fri, 16 Jan 2026 15:58:21 -0800 Subject: [PATCH 1/3] VAD algo and settings revision (#683) * new contexMenu component. could come in handy throughout the app * unified VAD settings and performance tweaks - settings are now all stored in localStore.ts - there were two default thresholds, 0.085 and 0.03. Now just 0.05 - additionally did some things to improve UI responsiveness * VAD drawer performance improvements * Reduced VAD pause length min to 0.1. - Introduced constants for VAD silence duration: minimum, maximum, and default values. - Updated comments and logic in localStore.ts to reflect new constants. - Adjusted increment/decrement handlers in VADSettingsDrawer to use the new constants for better maintainability and clarity. - Ensured that silence duration settings are validated against the defined constants to prevent invalid values. * Added circular meter on pause length setting to visualize pause. * Made pause length buttons more responsive. * New VAD algorithm * Implement minimum active audio duration in VAD * installed slider, renamed to 'Min segment length' and update default to 200ms * fixed counting when transient segments are eliminated * waveform update for rejected (short) segments * Update VAD settings UI - Enhanced localization descriptions for silence duration and minimum segment length. - Adjusted default silence duration from 300ms to 700ms. - Reduced minumin segment length slider max from 1000ms to 500ms. - Added reset functionality for silence duration and minimum segment length in the VAD settings drawer. - tried and failed to make slider draggable * VAD sensitivity default from 0.05 to 0.1 - plus failed attempts to improve UI performance * New SimpleDrawer component for VAD settings * VAD settings drawer tweaks, checkpoint * fixed sound meter width and tooltip appearance * Improved tooltip and other text * Code clean-up, removed simple drawer * final VAD settings UI tweaks * simplified onset buffer configuration * fixing typescript and prettier errors * fix tooltip positioning * fixed rewindHalfPause number->boolean --- components/AudioPlayer.tsx | 2 +- components/WaveformVisualization.tsx | 36 +- components/ui/context-menu.tsx | 369 ++++ components/ui/slider.tsx | 404 +++- components/ui/tooltip.tsx | 2 +- hooks/useMicrophoneEnergy.ts | 12 +- hooks/useMicrophoneEnergy.web.ts | 56 +- .../MicrophoneEnergyModule.kt | 565 ++---- .../ios/MicrophoneEnergyModule.swift | 584 ++---- .../src/MicrophoneEnergyModule.ts | 3 + package-lock.json | 1765 +++++++++++++++-- package.json | 8 +- services/localizations.ts | 123 +- store/localStore.ts | 80 +- .../components/FullScreenVADOverlay.tsx | 3 + .../components/RecordingControls.tsx | 5 +- .../components/RecordingViewSimplified.tsx | 20 +- .../components/VADSettingsDrawer.tsx | 1497 ++++++++------ views/new/recording/hooks/useVADRecording.ts | 126 +- 19 files changed, 4001 insertions(+), 1659 deletions(-) create mode 100644 components/ui/context-menu.tsx diff --git a/components/AudioPlayer.tsx b/components/AudioPlayer.tsx index 067f93a98..7119b3e77 100644 --- a/components/AudioPlayer.tsx +++ b/components/AudioPlayer.tsx @@ -132,7 +132,7 @@ const AudioPlayer: React.FC = ({ style={styles.audioProgressBar} max={duration || 100} value={isThisAudioPlaying ? position : 0} - onValueChange={(value) => handleSliderChange(value[0]!)} + onValueChange={(value) => handleSliderChange(value)} /> diff --git a/components/WaveformVisualization.tsx b/components/WaveformVisualization.tsx index b2a0919e0..015d1f273 100644 --- a/components/WaveformVisualization.tsx +++ b/components/WaveformVisualization.tsx @@ -71,6 +71,7 @@ interface WaveformVisualizationProps { energyShared: SharedValue; // OPTIMIZED: SharedValue instead of number vadThreshold: number; isRecordingShared: SharedValue; // OPTIMIZED: SharedValue for instant updates + isDiscardedShared?: SharedValue; // Trigger to revert recent red bars barCount?: number; maxHeight?: number; } @@ -80,6 +81,7 @@ export const WaveformVisualization: React.FC = ({ energyShared, vadThreshold, isRecordingShared, // Now a SharedValue - NO SYNC NEEDED! + isDiscardedShared, barCount = 60, maxHeight = 24 }) => { @@ -150,28 +152,28 @@ export const WaveformVisualization: React.FC = ({ // isRecording removed from deps - we use SharedValue now which updates without recreation ); - // TEMPORARILY DISABLED: React to recording state changes to update recent bars - // DISABLED FOR TESTING: This reaction might be causing issues if it fires too frequently - // The main reaction already sets the last bar's recording state on line 117, - // so this retroactive update might be redundant and could cause conflicts. - // TODO: Re-enable and optimize if needed, or remove if not necessary - /* + // Retroactive update: if a segment is discarded, turn all current red bars back to blue useAnimatedReaction( - () => isRecordingShared.value, - (isRecording, previousIsRecording) => { + () => isDiscardedShared?.value ?? 0, + (discardCount, previousDiscardCount) => { 'worklet'; - if (!isVisible || previousIsRecording === isRecording) return; - - // Update the most recent 5 bars to match the new recording state - // This ensures smooth color transitions when recording state changes - const barsToUpdate = Math.min(5, barCount); - for (let i = barCount - barsToUpdate; i < barCount; i++) { - waveformRecordingState[i]!.value = isRecording; + if ( + !isVisible || + discardCount === 0 || + discardCount === previousDiscardCount + ) + return; + + // When a discard happens, revert all red bars in the current view to blue + // This creates the "retroactive blue" effect + for (let i = 0; i < barCount; i++) { + if (waveformRecordingState[i]!.value) { + waveformRecordingState[i]!.value = false; + } } }, - [isVisible, barCount] + [isVisible, barCount, isDiscardedShared] ); - */ // Reset when hidden useEffect(() => { diff --git a/components/ui/context-menu.tsx b/components/ui/context-menu.tsx new file mode 100644 index 000000000..e5a1ae7bb --- /dev/null +++ b/components/ui/context-menu.tsx @@ -0,0 +1,369 @@ +/** + * ContextMenu Component + * + * A flexible context menu component that displays a popup menu when triggered. + * Automatically positions itself to avoid screen edges and can be dismissed by + * clicking outside the menu. + * + * Features: + * - Auto-positioning: Automatically flips to opposite side if menu would go off-screen + * - Smart alignment: Adjusts horizontal position to stay within screen bounds + * - Outside click dismissal: Clicking anywhere outside the menu closes it + * - Customizable trigger: Use default "..." icon or provide custom trigger element + * - Icon support: Menu items can include icons + * - Destructive styling: Mark items as destructive for delete/remove actions + * + * Usage: + * ```tsx + * handleEdit() + * }, + * { + * label: "Delete", + * icon: TrashIcon, + * destructive: true, + * onPress: () => handleDelete() + * } + * ]} + * /> + * + * // With custom trigger: + * Actions} + * items={[...]} + * /> + * + * // With scaled size: + * + * ``` + * + * Positioning Logic: + * - The menu measures both the trigger position and its own dimensions + * - If the menu would overflow the screen, it automatically flips to the opposite side + * - If it still doesn't fit, it positions itself at the screen edge with padding + * - Position is calculated once and locked to prevent jittering during layout measurements + */ + +import { Icon } from '@/components/ui/icon'; +import { Text } from '@/components/ui/text'; +import { cn } from '@/utils/styleUtils'; +import type { LucideIcon } from 'lucide-react-native'; +import { EllipsisVerticalIcon } from 'lucide-react-native'; +import React, { useEffect, useRef, useState } from 'react'; +import type { LayoutChangeEvent } from 'react-native'; +import { Modal, Pressable, View, useWindowDimensions } from 'react-native'; +import Animated, { + Easing, + useAnimatedStyle, + useSharedValue, + withTiming +} from 'react-native-reanimated'; + +/** + * Menu item configuration + */ +export interface ContextMenuItem { + label: string; + icon?: LucideIcon; + onPress: () => void; + destructive?: boolean; +} + +/** + * Base size values (at scale factor 1.0) + */ +const BASE_MENU_ITEM_PADDING_HORIZONTAL = 12; // px-3 +const BASE_MENU_ITEM_PADDING_VERTICAL = 8; // py-2 +const BASE_MENU_ITEM_GAP = 8; // gap-2 +const BASE_MENU_ITEM_ICON_SIZE = 16; +const BASE_MENU_ITEM_TEXT_SIZE = 16; // text-base + +/** + * Default trigger icon size + */ +const DEFAULT_TRIGGER_ICON_SIZE = 20; + +/** + * ContextMenu component props + */ +interface ContextMenuProps { + /** Custom trigger element. If not provided, defaults to "..." icon */ + trigger?: React.ReactNode; + /** Array of menu items to display */ + items: ContextMenuItem[]; + /** Which side of the trigger to show the menu (default: "bottom") */ + side?: 'top' | 'bottom'; + /** Horizontal alignment relative to trigger (default: "end" = right-aligned) */ + align?: 'left' | 'right' | 'start' | 'end'; + /** Scale factor for menu items (default: 1.0). Use values like 0.9, 1.2, etc. */ + size?: number; + /** Size of the trigger icon in pixels (default: 20) */ + triggerIconSize?: number; +} + +export function ContextMenu({ + trigger, + items, + side = 'top', + align = 'end', + size = 1.0, + triggerIconSize = DEFAULT_TRIGGER_ICON_SIZE +}: ContextMenuProps) { + // Calculate scaled values based on size factor + const menuItemPaddingHorizontal = Math.round( + BASE_MENU_ITEM_PADDING_HORIZONTAL * size + ); + const menuItemPaddingVertical = Math.round( + BASE_MENU_ITEM_PADDING_VERTICAL * size + ); + const menuItemGap = Math.round(BASE_MENU_ITEM_GAP * size); + const menuItemIconSize = Math.round(BASE_MENU_ITEM_ICON_SIZE * size); + const menuItemTextSize = Math.round(BASE_MENU_ITEM_TEXT_SIZE * size); + const [isOpen, setIsOpen] = useState(false); + const [isPositioned, setIsPositioned] = useState(false); + const [position, setPosition] = useState({ top: 0, left: 0 }); + const [actualSide, setActualSide] = useState<'top' | 'bottom'>('top'); + const triggerLayoutRef = useRef({ + x: 0, + y: 0, + width: 0, + height: 0 + }); + const triggerRef = useRef(null); + const { width: screenWidth, height: screenHeight } = useWindowDimensions(); + + // Animation values + const opacity = useSharedValue(0); + const translateY = useSharedValue(0); + + /** + * Opens the menu and measures the trigger's position on screen + * Uses measureInWindow to get absolute screen coordinates + */ + const handleOpen = () => { + setIsPositioned(false); + triggerRef.current?.measureInWindow((pageX, pageY, width, height) => { + triggerLayoutRef.current = { + x: Math.round(pageX), + y: Math.round(pageY), + width: Math.round(width), + height: Math.round(height) + }; + setIsOpen(true); + }); + }; + + /** + * Calculates menu position based on trigger location and menu dimensions. + * Returns position locked to screen bounds. + */ + const calculatePosition = ( + menuWidth: number, + menuHeight: number, + trigger: { x: number; y: number; width: number; height: number } + ) => { + const spacing = 8; + let top = 0; + let left = 0; + + // Vertical positioning + if (side === 'bottom') { + top = trigger.y + trigger.height + spacing; + if (top + menuHeight > screenHeight) { + top = trigger.y - menuHeight - spacing; + if (top < 0) { + top = screenHeight - menuHeight - 16; + } + } + } else { + top = trigger.y - menuHeight - spacing; + if (top < 0) { + top = trigger.y + trigger.height + spacing; + if (top + menuHeight > screenHeight) { + top = 16; + } + } + } + + // Horizontal positioning + if (align === 'end' || align === 'right') { + left = trigger.x + trigger.width - menuWidth; + if (left < 0) { + left = screenWidth - menuWidth - 16; + } + } else { + left = trigger.x; + if (left + menuWidth > screenWidth) { + left = 16; + } + } + + // Clamp to screen bounds + return { + top: Math.max(16, Math.min(top, screenHeight - menuHeight - 16)), + left: Math.max(16, Math.min(left, screenWidth - menuWidth - 16)) + }; + }; + + /** + * Called when the menu's layout is measured. + * Calculates position once and locks it to prevent feedback loops. + */ + const handleMenuLayout = (event: LayoutChangeEvent) => { + if (isPositioned) return; + + const { width, height } = event.nativeEvent.layout; + const menuWidth = Math.round(width); + const menuHeight = Math.round(height); + + if (menuWidth === 0 || menuHeight === 0) return; + + const newPosition = calculatePosition( + menuWidth, + menuHeight, + triggerLayoutRef.current + ); + + // Determine actual side: if menu top is above trigger center, it's above + const triggerCenterY = + triggerLayoutRef.current.y + triggerLayoutRef.current.height / 2; + const menuCenterY = newPosition.top + menuHeight / 2; + const actualSideValue = menuCenterY < triggerCenterY ? 'top' : 'bottom'; + + setActualSide(actualSideValue); + setPosition({ + top: Math.round(newPosition.top), + left: Math.round(newPosition.left) + }); + setIsPositioned(true); + }; + + const handleItemPress = (item: ContextMenuItem) => { + setIsOpen(false); + setIsPositioned(false); + // Reset animation values + opacity.value = 0; + translateY.value = 0; + item.onPress(); + }; + + // Animate when menu becomes positioned + useEffect(() => { + if (isPositioned && isOpen) { + // Determine animation direction based on actual side + // If menu appears above (top), slide upward (starts below, moves up) + // If menu appears below (bottom), slide downward (starts above, moves down) + const slideDistance = 8; // pixels + const initialTranslateY = + actualSide === 'top' ? slideDistance : -slideDistance; + + translateY.value = initialTranslateY; + opacity.value = 0; + + // Animate to final position - simple timing with ease-in, same duration as fade + const duration = 200; + translateY.value = withTiming(0, { + duration, + easing: Easing.in(Easing.ease) + }); + opacity.value = withTiming(1, { duration }); + } else if (!isOpen) { + // Reset when closed + opacity.value = 0; + translateY.value = 0; + } + }, [isPositioned, isOpen, actualSide, opacity, translateY]); + + // Animated style for the menu + const animatedStyle = useAnimatedStyle(() => { + return { + opacity: opacity.value, + transform: [{ translateY: translateY.value }] + }; + }); + + return ( + <> + + + {trigger || ( + + )} + + + + setIsOpen(false)} + > + setIsOpen(false)}> + + {items.map((item, index) => ( + handleItemPress(item)} + style={{ + paddingHorizontal: menuItemPaddingHorizontal, + paddingVertical: menuItemPaddingVertical, + gap: menuItemGap + }} + className={cn( + 'flex flex-row items-center rounded-sm active:bg-accent', + item.destructive && 'text-destructive' + )} + > + {item.icon && ( + + )} + + {item.label} + + + ))} + + + + + ); +} diff --git a/components/ui/slider.tsx b/components/ui/slider.tsx index 182c0507e..004a59f64 100644 --- a/components/ui/slider.tsx +++ b/components/ui/slider.tsx @@ -1,117 +1,359 @@ -import { cn } from '@/utils/styleUtils'; -import * as SliderPrimitive from '@rn-primitives/slider'; +import { cn, useThemeColor } from '@/utils/styleUtils'; import * as React from 'react'; -import { Platform } from 'react-native'; +import type { LayoutChangeEvent, StyleProp, ViewStyle } from 'react-native'; +import { View } from 'react-native'; +import { Gesture, GestureDetector } from 'react-native-gesture-handler'; import Animated, { Extrapolation, interpolate, + runOnJS, useAnimatedStyle, useDerivedValue, + useSharedValue, withSpring } from 'react-native-reanimated'; +interface SliderProps { + // API compatibility: support both naming conventions + min?: number; + max?: number; + minimumValue?: number; + maximumValue?: number; + value?: number; + step?: number; + onValueChange?: (value: number) => void; + // Tint color props for compatibility + minimumTrackTintColor?: string; + maximumTrackTintColor?: string; + thumbTintColor?: string; + // Style prop + style?: StyleProp; + disabled?: boolean; + className?: string; + // Whether to animate value changes (default: true) + animated?: boolean; +} + +/** + * Custom Slider component with gesture handling + * Does NOT use @rn-primitives/slider to avoid precision errors in native code + * All rendering and gesture handling is done in JS/React Native + */ function Slider({ className, - ...props -}: SliderPrimitive.RootProps & { max: number }) { - const min = props.min ?? 0; - const max = props.max; - const value = typeof props.value === 'number' ? props.value : 0; + min: minProp, + max: maxProp, + minimumValue, + maximumValue, + value: valueProp, + step, + onValueChange, + minimumTrackTintColor, + maximumTrackTintColor, + thumbTintColor, + style, + disabled, + animated = true +}: SliderProps) { + // API compatibility: map minimumValue/maximumValue to min/max + const min = minProp ?? minimumValue ?? 0; + const max = maxProp ?? maximumValue ?? 100; + const value = + typeof valueProp === 'number' && isFinite(valueProp) ? valueProp : min; - const percent = React.useMemo(() => { - const range = Math.max(1e-6, max - min); - const raw = ((value - min) / range) * 100; - return Math.max(0, Math.min(100, isFinite(raw) ? raw : 0)); + // Track width for calculations + const trackWidth = useSharedValue(0); + const isDragging = useSharedValue(false); + const dragValue = useSharedValue(value); + const startPercent = useSharedValue(0); // Capture start position for dragging + + // Clamp value to min/max range + const clampedValue = React.useMemo(() => { + const clamped = Math.max(min, Math.min(max, value)); + return isFinite(clamped) ? clamped : min; }, [min, max, value]); - return ( - - {/* Larger track for easier touch */} - - {Platform.OS === 'web' ? ( - - ) : ( - - )} - - - {Platform.OS === 'web' ? ( - - ) : ( - - )} - + // Calculate percentage (0-100) - used for initial render only + const _percent = React.useMemo(() => { + const range = Math.max(1e-6, max - min); + const raw = ((clampedValue - min) / range) * 100; + const result = Math.max(0, Math.min(100, raw)); + return isFinite(result) ? result : 0; + }, [min, max, clampedValue]); + + // Sync dragValue when value prop changes externally (programmatic updates) + React.useEffect(() => { + if (!isDragging.value) { + dragValue.value = clampedValue; + } + }, [clampedValue, isDragging, dragValue]); + + // Store min, max, step, animated in refs for worklet access + const minRef = React.useRef(min); + const maxRef = React.useRef(max); + const stepRef = React.useRef(step); + const animatedRef = React.useRef(animated); + React.useEffect(() => { + minRef.current = min; + maxRef.current = max; + stepRef.current = step; + animatedRef.current = animated; + }, [min, max, step, animated]); + + // Handle value change (with step snapping) + const handleValueChange = React.useCallback( + (newValue: number) => { + if (!isFinite(newValue)) return; + + let finalValue = newValue; + if (step && step > 0) { + // Use integer arithmetic for step snapping to avoid precision issues + const stepIndex = Math.round((newValue - min) / step); + finalValue = stepIndex * step + min; + } + const clamped = Math.max(min, Math.min(max, finalValue)); + if (isFinite(clamped)) { + onValueChange?.(clamped); + } + }, + [min, max, step, onValueChange] ); -} -export { Slider }; + // Store callback in ref for worklet access + const onValueChangeRef = React.useRef(handleValueChange); + React.useEffect(() => { + onValueChangeRef.current = handleValueChange; + }, [handleValueChange]); -interface NativeIndicatorProps { - percent: number; -} + // Pan gesture for dragging thumb + const panGesture = Gesture.Pan() + .enabled(!disabled) + .activeOffsetX([-10, 10]) // Horizontal movement activates + .failOffsetY([-5, 5]) // Vertical movement cancels (allows BottomSheet vertical drag) + .onStart(() => { + 'worklet'; + isDragging.value = true; + dragValue.value = clampedValue; + // Capture starting percent for relative dragging + const range = Math.max(1e-6, maxRef.current - minRef.current); + startPercent.value = ((clampedValue - minRef.current) / range) * 100; + }) + .onUpdate((event) => { + 'worklet'; + if (trackWidth.value === 0) return; + + // Calculate new position based on translation from start position + const trackWidthPx = trackWidth.value; + const translationPercent = (event.translationX / trackWidthPx) * 100; + const newPercent = Math.max( + 0, + Math.min(100, startPercent.value + translationPercent) + ); + + // Convert to value + const range = maxRef.current - minRef.current; + const rawValue = minRef.current + (newPercent / 100) * range; + let newValue = Math.max( + minRef.current, + Math.min(maxRef.current, rawValue) + ); + + // Apply step snapping using integer arithmetic + if (stepRef.current && stepRef.current > 0) { + const stepIndex = Math.round( + (newValue - minRef.current) / stepRef.current + ); + newValue = stepIndex * stepRef.current + minRef.current; + newValue = Math.max(minRef.current, Math.min(maxRef.current, newValue)); + } + + dragValue.value = newValue; + runOnJS(onValueChangeRef.current)(newValue); + }) + .onEnd(() => { + 'worklet'; + isDragging.value = false; + // Snap to final value (with optional spring animation) + let finalValue = dragValue.value; + if (stepRef.current && stepRef.current > 0) { + const stepIndex = Math.round( + (finalValue - minRef.current) / stepRef.current + ); + finalValue = stepIndex * stepRef.current + minRef.current; + finalValue = Math.max( + minRef.current, + Math.min(maxRef.current, finalValue) + ); + } + dragValue.value = animatedRef.current + ? withSpring(finalValue, { overshootClamping: true }) + : finalValue; + runOnJS(onValueChangeRef.current)(finalValue); + }) + .onFinalize(() => { + 'worklet'; + isDragging.value = false; + }); + + // Tap gesture for track tapping + const tapGesture = Gesture.Tap() + .enabled(!disabled) + .onEnd((event) => { + 'worklet'; + if (trackWidth.value === 0) return; + + // Calculate tap position relative to track + const tapPercent = Math.max( + 0, + Math.min(100, (event.x / trackWidth.value) * 100) + ); -function NativeRange({ percent }: NativeIndicatorProps) { - const progress = useDerivedValue(() => percent); + // Convert to value + const range = maxRef.current - minRef.current; + const rawValue = minRef.current + (tapPercent / 100) * range; + let newValue = Math.max( + minRef.current, + Math.min(maxRef.current, rawValue) + ); - const animatedStyle = useAnimatedStyle(() => { + // Apply step snapping using integer arithmetic + if (stepRef.current && stepRef.current > 0) { + const stepIndex = Math.round( + (newValue - minRef.current) / stepRef.current + ); + newValue = stepIndex * stepRef.current + minRef.current; + newValue = Math.max(minRef.current, Math.min(maxRef.current, newValue)); + } + + dragValue.value = animatedRef.current + ? withSpring(newValue, { overshootClamping: true }) + : newValue; + runOnJS(onValueChangeRef.current)(newValue); + }); + + // Combined gesture (tap on track, pan on thumb) + const composedGesture = Gesture.Race(tapGesture, panGesture); + + // Track layout handler + const handleTrackLayout = React.useCallback( + (event: LayoutChangeEvent) => { + const { width } = event.nativeEvent.layout; + trackWidth.value = width; + }, + [trackWidth] + ); + + // Animated value for display (use dragValue when dragging, otherwise use prop value) + const displayValue = useDerivedValue(() => { + return isDragging.value ? dragValue.value : clampedValue; + }, [clampedValue]); + + // Calculate display percentage + const displayPercent = useDerivedValue(() => { + const val = displayValue.value; + const range = Math.max(1e-6, max - min); + const raw = ((val - min) / range) * 100; + return Math.max(0, Math.min(100, isFinite(raw) ? raw : 0)); + }, [min, max]); + + // Theme colors (fallback to props if provided) - must be called unconditionally + const themePrimary = useThemeColor('primary'); + const themeMuted = useThemeColor('muted'); + const themeBackground = useThemeColor('background'); + const primaryColor = thumbTintColor ?? themePrimary; + const mutedColor = maximumTrackTintColor ?? themeMuted; + const rangeColor = minimumTrackTintColor ?? primaryColor; + + // Animated styles for track range (filled portion) + const rangeStyle = useAnimatedStyle(() => { const widthPercent = interpolate( - progress.value, + displayPercent.value, + [0, 100], [0, 100], - [1, 100], Extrapolation.CLAMP ); return { - width: withSpring(`${widthPercent}%`, { overshootClamping: true }) + width: `${widthPercent}%` }; }, []); - return ( - - - - ); -} + // Animated styles for thumb position + const thumbStyle = useAnimatedStyle(() => { + const leftPercent = interpolate( + displayPercent.value, + [0, 100], + [0, 100], + Extrapolation.CLAMP + ); -function NativeThumb({ percent }: NativeIndicatorProps) { - const progress = useDerivedValue(() => percent); + // Always use direct value if animations are disabled or during dragging + if (!animatedRef.current || isDragging.value) { + return { + left: `${leftPercent}%` + }; + } - const animatedStyle = useAnimatedStyle(() => { + // Use spring for programmatic changes when animated return { - left: withSpring( - `${interpolate(progress.value, [0, 100], [0, 100], Extrapolation.CLAMP)}%`, - { - overshootClamping: true - } - ) + left: withSpring(`${leftPercent}%`, { + overshootClamping: true + }) }; }, []); return ( - - - + + + + {/* Track background */} + + {/* Track range (filled portion) */} + + + + {/* Thumb */} + + + + ); } + +export { Slider }; diff --git a/components/ui/tooltip.tsx b/components/ui/tooltip.tsx index 7a900d95b..f53b22442 100644 --- a/components/ui/tooltip.tsx +++ b/components/ui/tooltip.tsx @@ -203,7 +203,7 @@ const FullWindowOverlay = Platform.OS === 'ios' ? RNFullWindowOverlay : React.Fragment; const tooltipVariants = cva( - 'z-[6000] rounded-md border border-border bg-background px-3 py-2 sm:py-1.5' + 'z-[6000] rounded-md border border-border bg-background px-3 py-2 shadow-lg sm:py-1.5' ); const tooltipTextVariants = cva('text-xs text-foreground'); diff --git a/hooks/useMicrophoneEnergy.ts b/hooks/useMicrophoneEnergy.ts index 2ad0ef95c..f037c6fac 100644 --- a/hooks/useMicrophoneEnergy.ts +++ b/hooks/useMicrophoneEnergy.ts @@ -19,8 +19,10 @@ interface UseMicrophoneEnergy extends UseMicrophoneEnergyState { resetEnergy: () => void; startSegment: (options?: { prerollMs?: number }) => Promise; stopSegment: () => Promise; - // NEW: SharedValue for high-performance UI updates + // SharedValue for high-performance UI updates (no re-renders!) energyShared: SharedValue; + // Ref for logic that needs the latest value without re-renders + energyRef: { current: number }; } export function useMicrophoneEnergy(): UseMicrophoneEnergy { @@ -30,9 +32,12 @@ export function useMicrophoneEnergy(): UseMicrophoneEnergy { error: null }); - // NEW: SharedValue for high-performance UI updates (no re-renders!) + // SharedValue for high-performance UI updates (no re-renders!) const energyShared = useSharedValue(0); + // Ref for logic that needs the latest value (calibration, etc.) - NO re-renders + const energyRef = useRef(0); + // Ref to track active state to avoid stale closures const isActiveRef = useRef(false); @@ -187,6 +192,7 @@ export function useMicrophoneEnergy(): UseMicrophoneEnergy { resetEnergy, startSegment, stopSegment, - energyShared + energyShared, + energyRef }; } diff --git a/hooks/useMicrophoneEnergy.web.ts b/hooks/useMicrophoneEnergy.web.ts index 6b4c81d5f..1da927da2 100644 --- a/hooks/useMicrophoneEnergy.web.ts +++ b/hooks/useMicrophoneEnergy.web.ts @@ -6,6 +6,8 @@ */ import { useCallback, useEffect, useRef, useState } from 'react'; +import type { SharedValue } from 'react-native-reanimated'; +import { useSharedValue } from 'react-native-reanimated'; export interface MicrophoneEnergyResult { energy: number; // Normalized energy level (0-1) @@ -21,6 +23,13 @@ export interface UseMicrophoneEnergyReturn { stopEnergyDetection: () => Promise; clearError: () => void; resetEnergy: () => void; + requestPermissions: () => Promise; + startSegment: (options?: { prerollMs?: number }) => Promise; + stopSegment: () => Promise; + // SharedValue for high-performance UI updates (no re-renders!) + energyShared: SharedValue; + // Ref for logic that needs the latest value without re-renders + energyRef: { current: number }; } export function useMicrophoneEnergy(): UseMicrophoneEnergyReturn { @@ -29,6 +38,12 @@ export function useMicrophoneEnergy(): UseMicrophoneEnergyReturn { useState(null); const [error, setError] = useState(null); + // SharedValue for high-performance UI updates (no re-renders!) + const energyShared = useSharedValue(0); + + // Ref for logic that needs the latest value without re-renders + const energyRef = useRef(0); + // Audio context and stream refs const audioContextRef = useRef(null); const analyserRef = useRef(null); @@ -144,7 +159,12 @@ export function useMicrophoneEnergy(): UseMicrophoneEnergyReturn { currentSmoothed * (1 - SMOOTHING_FACTOR) + rms * SMOOTHING_FACTOR; smoothedEnergyRef.current = newSmoothed; - // Update state + // Update SharedValue for UI (no re-render) + energyShared.value = rms; + // Update ref for logic that needs it (no re-render) + energyRef.current = rms; + + // Update state (for legacy consumers) setEnergyResult({ energy: rms, smoothedEnergy: newSmoothed, @@ -183,6 +203,33 @@ export function useMicrophoneEnergy(): UseMicrophoneEnergyReturn { const resetEnergy = useCallback(() => { setEnergyResult(null); smoothedEnergyRef.current = 0; + energyShared.value = 0; + energyRef.current = 0; + }, [energyShared]); + + // Stub implementations for compatibility with native interface + const requestPermissions = useCallback(async (): Promise => { + try { + const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); + stream.getTracks().forEach((track) => track.stop()); + return true; + } catch { + return false; + } + }, []); + + const startSegment = useCallback( + async (_options?: { prerollMs?: number }) => { + // Web doesn't support segment recording - this is a native-only feature + console.warn('startSegment is not supported on web'); + }, + [] + ); + + const stopSegment = useCallback(async (): Promise => { + // Web doesn't support segment recording - this is a native-only feature + console.warn('stopSegment is not supported on web'); + return null; }, []); // Cleanup on unmount @@ -199,6 +246,11 @@ export function useMicrophoneEnergy(): UseMicrophoneEnergyReturn { startEnergyDetection, stopEnergyDetection, clearError, - resetEnergy + resetEnergy, + requestPermissions, + startSegment, + stopSegment, + energyShared, + energyRef }; } diff --git a/modules/microphone-energy/android/src/main/java/expo/modules/microphoneenergy/MicrophoneEnergyModule.kt b/modules/microphone-energy/android/src/main/java/expo/modules/microphoneenergy/MicrophoneEnergyModule.kt index e03923bc0..9ac4377e4 100644 --- a/modules/microphone-energy/android/src/main/java/expo/modules/microphoneenergy/MicrophoneEnergyModule.kt +++ b/modules/microphone-energy/android/src/main/java/expo/modules/microphoneenergy/MicrophoneEnergyModule.kt @@ -1,16 +1,15 @@ -package expo.modules.microphoneenergy +package expo.modules.microphoneenergy -import android.Manifest -import android.content.pm.PackageManager import android.media.AudioFormat import android.media.AudioRecord import android.media.MediaRecorder -import androidx.core.content.ContextCompat import expo.modules.kotlin.Promise import expo.modules.kotlin.modules.Module import expo.modules.kotlin.modules.ModuleDefinition import kotlinx.coroutines.* -import kotlin.math.sqrt +import kotlin.math.abs +import kotlin.math.log10 +import kotlin.math.max import kotlin.math.pow class MicrophoneEnergyModule : Module() { @@ -18,159 +17,93 @@ class MicrophoneEnergyModule : Module() { private var isActive = false private var recordingScope: CoroutineScope? = null - // Ring buffer for capturing speech onset (200ms preroll) - // Store tuples of (buffer, timestamp) to enable time-based clearing - private data class RingBufferEntry( - val buffer: ShortArray, - val timestamp: Long - ) + private data class RingBufferEntry(val buffer: ShortArray, val timestamp: Long) private val ringBuffer = ArrayDeque() - private val ringBufferMaxSize = 7 // ~200ms at typical buffer sizes + private val ringBufferMaxSize = 10 + private var isRecordingSegment = false private var segmentFile: java.io.File? = null private var segmentStartTime: Long = 0 private val sampleRate = 44100 - - // Segment audio data collected in memory private var segmentBuffers = ArrayList() - // Native VAD state private var vadEnabled = false - private var vadThreshold = 0.5f - private var vadOnsetMultiplier = 0.25f - private var vadConfirmMultiplier = 0.5f - private var vadSilenceDuration = 300 // ms - private var vadMinSegmentDuration = 500 // ms - - // EMA smoothing - private val emaAlpha = 0.3f - private var smoothedEnergy = 0.0f + private var vadThreshold = 0.05f + private var vadOnsetMultiplier = 0.1f + private var vadMaxOnsetDuration = 250 + private var vadSilenceDuration = 300 + private var vadMinSegmentDuration = 500 + private var vadRewindHalfPause = true + private var vadMinActiveAudioDuration = 250 // Discard clips with less active audio than this - // Schmitt trigger state - private var onsetDetected = false - private var onsetTime: Long = 0 - private var lastSpeechTime: Long = 0 + private var vadState = "IDLE" + private var preOnsetCutPoint: Long = 0 + private var lockedOnsetTime: Long = 0 + private var lastAboveThresholdTime: Long = 0 private var recordingStartTime: Long = 0 - private var lastSegmentEndTime: Long = 0 // Track when last segment ended - private val cooldownPeriodMs = 500 // Cooldown after segment ends before detecting new onset + private var activeAudioTime: Long = 0 // Cumulative time above threshold during recording + private var lastFrameTime: Long = 0 // For calculating delta time override fun definition() = ModuleDefinition { Name("MicrophoneEnergy") - Events("onEnergyResult", "onError", "onSegmentComplete", "onSegmentStart") - AsyncFunction("startEnergyDetection") { promise: Promise -> - startEnergyDetection(promise) - } - - AsyncFunction("stopEnergyDetection") { promise: Promise -> - stopEnergyDetection(promise) - } - + AsyncFunction("startEnergyDetection") { promise: Promise -> startEnergyDetection(promise) } + AsyncFunction("stopEnergyDetection") { promise: Promise -> stopEnergyDetection(promise) } AsyncFunction("configureVAD") { config: Map, promise: Promise -> configureVAD(config) promise.resolve(null) } - - AsyncFunction("enableVAD") { promise: Promise -> - enableVAD() - promise.resolve(null) - } - - AsyncFunction("disableVAD") { promise: Promise -> - disableVAD() - promise.resolve(null) - } - - AsyncFunction("startSegment") { options: Map?, promise: Promise -> - startSegment(options, promise) - } - - AsyncFunction("stopSegment") { promise: Promise -> - stopSegment(promise) - } + AsyncFunction("enableVAD") { promise: Promise -> enableVAD(); promise.resolve(null) } + AsyncFunction("disableVAD") { promise: Promise -> disableVAD(); promise.resolve(null) } + AsyncFunction("startSegment") { options: Map?, promise: Promise -> startSegment(options, promise) } + AsyncFunction("stopSegment") { promise: Promise -> stopSegment(promise) } } private fun startEnergyDetection(promise: Promise) { - if (isActive) { - // Restart: stop current detection first - stopEnergyDetectionInternal() - } - + if (isActive) stopEnergyDetectionInternal() try { val channelConfig = AudioFormat.CHANNEL_IN_MONO val audioFormat = AudioFormat.ENCODING_PCM_16BIT val bufferSize = AudioRecord.getMinBufferSize(sampleRate, channelConfig, audioFormat) - - // Use DEFAULT audio source to allow system to choose best available microphone - // This matches expo-av's approach and provides better audio quality - audioRecord = AudioRecord( - MediaRecorder.AudioSource.DEFAULT, - sampleRate, - channelConfig, - audioFormat, - bufferSize - ) - - if (audioRecord?.state != AudioRecord.STATE_INITIALIZED) { - throw Exception("AudioRecord initialization failed") - } - + audioRecord = AudioRecord(MediaRecorder.AudioSource.DEFAULT, sampleRate, channelConfig, audioFormat, bufferSize) + if (audioRecord?.state != AudioRecord.STATE_INITIALIZED) throw Exception("AudioRecord initialization failed") audioRecord?.startRecording() isActive = true promise.resolve(null) - recordingScope = CoroutineScope(Dispatchers.IO) recordingScope?.launch { - val audioData = ShortArray(bufferSize / 2) // For 16-bit PCM + val audioData = ShortArray(bufferSize / 2) while (isActive && audioRecord != null) { val bytesRead = audioRecord!!.read(audioData, 0, audioData.size) - if (bytesRead > 0) { - processAudioData(audioData, bytesRead, sampleRate) - } + if (bytesRead > 0) processAudioData(audioData, bytesRead) } } - } catch (e: SecurityException) { - sendEvent("onError", mapOf("message" to "Microphone permission not granted: ${e.message}")) + sendEvent("onError", mapOf("message" to "Microphone permission not granted")) promise.reject("PERMISSION_DENIED", "Microphone permission not granted", e) } catch (e: Exception) { - sendEvent("onError", mapOf("message" to "Failed to start energy detection: ${e.message}")) - promise.reject("ENERGY_DETECTION_ERROR", "Failed to start energy detection", e) + sendEvent("onError", mapOf("message" to "Failed to start: ${e.message}")) + promise.reject("ENERGY_DETECTION_ERROR", "Failed to start", e) } } private fun stopEnergyDetection(promise: Promise) { - if (!isActive) { - promise.resolve(null) - return - } - - try { - stopEnergyDetectionInternal() - promise.resolve(null) - } catch (e: Exception) { - sendEvent("onError", mapOf("message" to "Failed to stop energy detection: ${e.message}")) - promise.reject("STOP_ERROR", "Failed to stop energy detection", e) - } + if (!isActive) { promise.resolve(null); return } + try { stopEnergyDetectionInternal(); promise.resolve(null) } + catch (e: Exception) { promise.reject("STOP_ERROR", "Failed to stop", e) } } private fun stopEnergyDetectionInternal() { isActive = false vadEnabled = false - onsetDetected = false + vadState = "IDLE" recordingScope?.cancel() recordingScope = null - - // Stop any active segment if (isRecordingSegment) { - val promise = object : Promise { - override fun resolve(value: Any?) {} - override fun reject(code: String, message: String?, cause: Throwable?) {} - } - stopSegment(promise) + val p = object : Promise { override fun resolve(value: Any?) {}; override fun reject(code: String, message: String?, cause: Throwable?) {} } + stopSegment(p) } - audioRecord?.stop() audioRecord?.release() audioRecord = null @@ -179,352 +112,200 @@ class MicrophoneEnergyModule : Module() { private fun configureVAD(config: Map) { (config["threshold"] as? Number)?.let { vadThreshold = it.toFloat() } (config["silenceDuration"] as? Number)?.let { vadSilenceDuration = it.toInt() } - (config["onsetMultiplier"] as? Number)?.let { vadOnsetMultiplier = it.toFloat() } - (config["confirmMultiplier"] as? Number)?.let { vadConfirmMultiplier = it.toFloat() } (config["minSegmentDuration"] as? Number)?.let { vadMinSegmentDuration = it.toInt() } - - println("🎯 VAD configured: threshold=$vadThreshold, silence=${vadSilenceDuration}ms") + (config["onsetMultiplier"] as? Number)?.let { vadOnsetMultiplier = it.toFloat() } + (config["maxOnsetDuration"] as? Number)?.let { vadMaxOnsetDuration = it.toInt() } + (config["rewindHalfPause"] as? Boolean)?.let { vadRewindHalfPause = it } + (config["minActiveAudioDuration"] as? Number)?.let { vadMinActiveAudioDuration = it.toInt() } } private fun enableVAD() { - vadEnabled = true - onsetDetected = false - smoothedEnergy = 0.0f - lastSegmentEndTime = 0L // Reset cooldown when VAD enabled - println("🎯 Native VAD enabled") + vadEnabled = true; vadState = "IDLE"; preOnsetCutPoint = 0; lockedOnsetTime = 0; lastAboveThresholdTime = 0 } private fun disableVAD() { - vadEnabled = false - onsetDetected = false - - // Stop any active segment + vadEnabled = false; vadState = "IDLE" if (isRecordingSegment) { - val promise = object : Promise { - override fun resolve(value: Any?) {} - override fun reject(code: String, message: String?, cause: Throwable?) {} - } - stopSegment(promise) + val p = object : Promise { override fun resolve(value: Any?) {}; override fun reject(code: String, message: String?, cause: Throwable?) {} } + stopSegment(p) } - - println("🎯 Native VAD disabled") } - private suspend fun processAudioData(audioData: ShortArray, bytesRead: Int, _sampleRate: Int) { - val timestamp = System.currentTimeMillis().toDouble() - - // Copy the data for ring buffer + private suspend fun processAudioData(audioData: ShortArray, bytesRead: Int) { + val now = System.currentTimeMillis() val dataCopy = audioData.copyOf(bytesRead) - - // Calculate peak amplitude (max absolute value) - matching expo-av's approach - // expo-av uses getMaxAmplitude() which returns peak, not RMS var peakAmplitude = 0.0 - for (i in 0 until bytesRead) { - val sample = kotlin.math.abs(audioData[i] / 32768.0) // Normalize to 0-1.0, take absolute - peakAmplitude = kotlin.math.max(peakAmplitude, sample) + for (i in 0 until bytesRead) { peakAmplitude = max(peakAmplitude, abs(audioData[i] / 32768.0)) } + val db = 20.0 * log10(max(peakAmplitude, 1e-10)) + val clampedDb = max(-60.0, kotlin.math.min(0.0, db)) + val normalizedAmplitude = 10.0.pow(clampedDb / 20.0).toFloat() + + synchronized(ringBuffer) { + ringBuffer.addLast(RingBufferEntry(dataCopy, now)) + if (ringBuffer.size > ringBufferMaxSize) ringBuffer.removeFirst() } + if (isRecordingSegment) synchronized(segmentBuffers) { segmentBuffers.add(dataCopy) } + if (vadEnabled) handleVAD(normalizedAmplitude, now) + withContext(Dispatchers.Main) { sendEvent("onEnergyResult", mapOf("energy" to normalizedAmplitude.toDouble(), "timestamp" to now.toDouble())) } + } + + private fun handleVAD(rawPeak: Float, now: Long) { + val onsetThreshold = vadThreshold * vadOnsetMultiplier + if (rawPeak > vadThreshold) lastAboveThresholdTime = now - // Convert peak amplitude to dB using expo-av's formula - // expo-av: dB = 20 * log10(amplitude / 32767) for Android - // For normalized amplitude (0-1), we use reference of 1.0 - // dB = 20 * log10(peakAmplitude / 1.0) = 20 * log10(peakAmplitude) - val minDb = -60.0 // Match expo-av's minimum dB - val maxDb = 0.0 // Match expo-av's maximum dB - - // Convert peak amplitude to dB - // Add small epsilon to avoid log(0) - val epsilon = 1e-10 - val db = 20.0 * kotlin.math.log10(kotlin.math.max(peakAmplitude, epsilon)) - - // Clamp dB to expo-av's range (-60 to 0) - val clampedDb = kotlin.math.max(minDb, kotlin.math.min(maxDb, db)) - - // Convert dB back to amplitude (matching expo-av's conversion) - // amplitude = 10^(dB/20) - val amplitude = 10.0.pow(clampedDb / 20.0) - - // Apply EMA smoothing on the amplitude (to match expo-av's output range) - smoothedEnergy = emaAlpha * amplitude.toFloat() + (1.0f - emaAlpha) * smoothedEnergy - - // Manage ring buffer (always buffer when not recording segment) - if (!isRecordingSegment) { - synchronized(ringBuffer) { - ringBuffer.addLast(RingBufferEntry(buffer = dataCopy, timestamp = timestamp.toLong())) - if (ringBuffer.size > ringBufferMaxSize) { - ringBuffer.removeFirst() + when (vadState) { + "IDLE" -> { + preOnsetCutPoint = max(0, now - vadMaxOnsetDuration) + if (rawPeak > onsetThreshold) { + vadState = "ONSET_PENDING"; lockedOnsetTime = preOnsetCutPoint + if (rawPeak > vadThreshold) confirmAndStartRecording(now) } } - } - - // If recording a segment, collect in memory - if (isRecordingSegment) { - synchronized(segmentBuffers) { - segmentBuffers.add(dataCopy) + "ONSET_PENDING" -> { + if (now - lockedOnsetTime > vadMaxOnsetDuration) lockedOnsetTime = now - vadMaxOnsetDuration + when { rawPeak > vadThreshold -> confirmAndStartRecording(now); rawPeak <= onsetThreshold -> vadState = "IDLE" } + } + "RECORDING" -> { + // Track cumulative time above threshold + val deltaMs = now - lastFrameTime + if (rawPeak > vadThreshold) { + activeAudioTime += deltaMs + } + lastFrameTime = now + + val silenceMs = now - lastAboveThresholdTime + val durationMs = now - recordingStartTime + if (silenceMs >= vadSilenceDuration && durationMs >= vadMinSegmentDuration) stopRecordingAsync() } - } - - // Native VAD logic (if enabled) - if (vadEnabled) { - handleNativeVAD() - } - - // Send energy level to JavaScript (for UI visualization) - withContext(Dispatchers.Main) { - sendEvent("onEnergyResult", mapOf( - "energy" to smoothedEnergy.toDouble(), - "timestamp" to timestamp - )) } } - private fun handleNativeVAD() { - val now = System.currentTimeMillis() - val onsetThreshold = vadThreshold * vadOnsetMultiplier - val confirmThreshold = vadThreshold * vadConfirmMultiplier + private fun confirmAndStartRecording(now: Long) { + vadState = "RECORDING"; lastAboveThresholdTime = now; recordingStartTime = now + activeAudioTime = 0; lastFrameTime = now // Reset active audio tracking + sendEvent("onSegmentStart", emptyMap()) + val prerollMs = (now - lockedOnsetTime).toInt() + val p = object : Promise { override fun resolve(value: Any?) {}; override fun reject(code: String, message: String?, cause: Throwable?) {} } + startSegment(mapOf("prerollMs" to prerollMs), p) + } + + private fun stopRecordingAsync() { + if (!isRecordingSegment) return + isRecordingSegment = false; vadState = "IDLE" - // Update last speech time if above confirm threshold - if (smoothedEnergy > confirmThreshold) { - lastSpeechTime = now + // Check if enough active audio - discard transients/short sounds + if (activeAudioTime < vadMinActiveAudioDuration) { + println("VAD: Discarding segment - only ${activeAudioTime}ms of active audio (min: ${vadMinActiveAudioDuration}ms)") + segmentBuffers.clear() + segmentFile?.delete() // Clean up temp file + segmentFile = null + + // Emit empty URI to notify JS that recording stopped but was discarded + sendEvent("onSegmentComplete", mapOf("uri" to "", "duration" to 0.0)) + return } - // State machine - if (!isRecordingSegment && !onsetDetected) { - // IDLE: Check for onset (with cooldown to prevent rapid re-triggers) - val timeSinceLastSegment = now - lastSegmentEndTime - if (smoothedEnergy > onsetThreshold) { - if (timeSinceLastSegment >= cooldownPeriodMs || lastSegmentEndTime == 0L) { - println("🎯 Native VAD: Onset detected ($smoothedEnergy > $onsetThreshold)") - onsetDetected = true - onsetTime = now - } else { - // Still in cooldown period - ignore onset - println("⏳ Native VAD: Onset ignored (cooldown: ${timeSinceLastSegment}ms/${cooldownPeriodMs}ms)") - } - } - } else if (!isRecordingSegment && onsetDetected) { - // ONSET: Wait for confirmation or timeout - val timeSinceOnset = now - onsetTime - - if (smoothedEnergy > confirmThreshold) { - println("🎤 Native VAD: Speech CONFIRMED ($smoothedEnergy > $confirmThreshold) - auto-starting segment") - - // Start recording segment - onsetDetected = false - lastSpeechTime = now - recordingStartTime = now - - // Emit event to JS (for UI update - create pending card) - sendEvent("onSegmentStart", emptyMap()) - - // Start segment with preroll - val promise = object : Promise { - override fun resolve(value: Any?) {} - override fun reject(code: String, message: String?, cause: Throwable?) { - println("⚠️ Native VAD: Failed to start segment: $message") - } - } - startSegment(mapOf("prerollMs" to 200), promise) - } else if (timeSinceOnset > 300) { - // Timeout - false alarm - println("⚠️ Native VAD: Onset timeout - false alarm") - onsetDetected = false - } - } else if (isRecordingSegment) { - // RECORDING: Monitor for silence - val silenceMs = now - lastSpeechTime - val durationMs = now - recordingStartTime - - if (silenceMs >= vadSilenceDuration && durationMs >= vadMinSegmentDuration) { - println("💤 Native VAD: ${silenceMs}ms silence - auto-stopping segment") - - // Stop segment (will emit onSegmentComplete) - val promise = object : Promise { - override fun resolve(value: Any?) {} - override fun reject(code: String, message: String?, cause: Throwable?) { - println("⚠️ Native VAD: Failed to stop segment: $message") + val buffersToWrite = ArrayList(segmentBuffers) + val fileToWrite = segmentFile + val startTime = segmentStartTime + val endTime = System.currentTimeMillis() + val rewindMs = if (vadRewindHalfPause) vadSilenceDuration / 2 else 0 + segmentBuffers.clear(); segmentFile = null + + CoroutineScope(Dispatchers.IO).launch { + try { + if (fileToWrite != null) { + writeWavFileAsync(fileToWrite, buffersToWrite, rewindMs) + val uri = "file://${fileToWrite.absolutePath}" + withContext(Dispatchers.Main) { + sendEvent("onSegmentComplete", mapOf("uri" to uri, "startTime" to startTime.toDouble(), "endTime" to (endTime - rewindMs).toDouble(), "duration" to (endTime - startTime - rewindMs).toDouble())) } } - stopSegment(promise) - } + } catch (e: Exception) { println("Error writing WAV: ${e.message}") } } } + + private fun writeWavFileAsync(file: java.io.File, buffers: ArrayList, rewindMs: Int) { + val samplesToTrim = sampleRate * rewindMs / 1000 + var totalSamples = 0 + for (buffer in buffers) totalSamples += buffer.size + val finalSamples = max(0, totalSamples - samplesToTrim) + val dataSize = finalSamples * 2L + val out = java.io.FileOutputStream(file) + writeWavHeader(out, dataSize, sampleRate, 1, 16) + var samplesWritten = 0 + for (buffer in buffers) { + val samplesToWrite = kotlin.math.min(buffer.size, finalSamples - samplesWritten) + if (samplesToWrite <= 0) break + val byteBuffer = java.nio.ByteBuffer.allocate(samplesToWrite * 2) + byteBuffer.order(java.nio.ByteOrder.LITTLE_ENDIAN) + for (i in 0 until samplesToWrite) byteBuffer.putShort(buffer[i]) + out.write(byteBuffer.array()) + samplesWritten += samplesToWrite + } + out.flush(); out.close() + } private fun writeWavHeader(out: java.io.FileOutputStream, dataSize: Long, sampleRate: Int, channels: Int, bitsPerSample: Int) { val header = java.nio.ByteBuffer.allocate(44) header.order(java.nio.ByteOrder.LITTLE_ENDIAN) - - // RIFF header - header.put("RIFF".toByteArray()) - header.putInt((36 + dataSize).toInt()) // File size - 8 - header.put("WAVE".toByteArray()) - - // fmt chunk - header.put("fmt ".toByteArray()) - header.putInt(16) // fmt chunk size - header.putShort(1) // Audio format (1 = PCM) - header.putShort(channels.toShort()) - header.putInt(sampleRate) - header.putInt(sampleRate * channels * bitsPerSample / 8) // Byte rate - header.putShort((channels * bitsPerSample / 8).toShort()) // Block align - header.putShort(bitsPerSample.toShort()) - - // data chunk - header.put("data".toByteArray()) - header.putInt(dataSize.toInt()) - + header.put("RIFF".toByteArray()); header.putInt((36 + dataSize).toInt()); header.put("WAVE".toByteArray()) + header.put("fmt ".toByteArray()); header.putInt(16); header.putShort(1); header.putShort(channels.toShort()) + header.putInt(sampleRate); header.putInt(sampleRate * channels * bitsPerSample / 8) + header.putShort((channels * bitsPerSample / 8).toShort()); header.putShort(bitsPerSample.toShort()) + header.put("data".toByteArray()); header.putInt(dataSize.toInt()) out.write(header.array()) } private fun startSegment(options: Map?, promise: Promise) { - if (!isActive) { - promise.reject("NOT_ACTIVE", "Energy detection not active", null) - return - } - - if (isRecordingSegment) { - println("⚠️ Segment already recording, ignoring duplicate start") - promise.resolve(null) - return - } - + if (!isActive) { promise.reject("NOT_ACTIVE", "Energy detection not active", null); return } + if (isRecordingSegment) { promise.resolve(null); return } try { - // Get preroll duration (default 200ms) val prerollMs = (options?.get("prerollMs") as? Number)?.toInt() ?: 200 - - // Create temp file for segment (WAV format for compatibility) val context = appContext.reactContext ?: throw Exception("Context not available") - val tempDir = context.cacheDir - val fileName = "segment_${java.util.UUID.randomUUID()}.wav" - val file = java.io.File(tempDir, fileName) - - segmentFile = file - segmentStartTime = System.currentTimeMillis() - - // Copy preroll from ring buffer + val file = java.io.File(context.cacheDir, "segment_${java.util.UUID.randomUUID()}.wav") + segmentFile = file; segmentStartTime = System.currentTimeMillis() synchronized(ringBuffer) { - val samplesPerMs = sampleRate / 1000.0 - val typicalBufferSize = 2048 - val maxPrerollBuffers = (prerollMs / (typicalBufferSize / samplesPerMs)).toInt() - val buffersToWrite = minOf(ringBuffer.size, maxPrerollBuffers) - + val maxPrerollBuffers = (prerollMs / (2048.0 / (sampleRate / 1000.0))).toInt() + val buffersToWrite = kotlin.math.min(ringBuffer.size, maxPrerollBuffers) segmentBuffers.clear() - // Copy buffers (not entries) from ring buffer - for (entry in ringBuffer.takeLast(buffersToWrite)) { - segmentBuffers.add(entry.buffer) - } - - // Don't clear ring buffer here - it will be cleared on segment end up to that point - println("📼 Preroll: $buffersToWrite chunks (~${prerollMs}ms)") + for (entry in ringBuffer.takeLast(buffersToWrite)) segmentBuffers.add(entry.buffer) } - - isRecordingSegment = true - println("🎬 Segment recording started with preroll") - promise.resolve(null) - + isRecordingSegment = true; promise.resolve(null) } catch (e: Exception) { - promise.reject("START_SEGMENT_ERROR", "Failed to start segment: ${e.message}", e) - isRecordingSegment = false - segmentBuffers.clear() - segmentFile = null + promise.reject("START_SEGMENT_ERROR", "Failed to start segment", e) + isRecordingSegment = false; segmentBuffers.clear(); segmentFile = null } } private fun stopSegment(promise: Promise) { - if (!isRecordingSegment) { - println("⚠️ No segment recording active") - promise.resolve(null) - return - } - + if (!isRecordingSegment) { promise.resolve(null); return } try { - isRecordingSegment = false - - // Record when segment ended for cooldown logic - lastSegmentEndTime = System.currentTimeMillis() - - val file = segmentFile - val startTime = segmentStartTime - val endTime = lastSegmentEndTime - val duration = endTime - startTime - + isRecordingSegment = false; vadState = "IDLE" + val file = segmentFile; val startTime = segmentStartTime; val endTime = System.currentTimeMillis() if (file != null) { - // Calculate total data size var totalSamples = 0 - synchronized(segmentBuffers) { - for (buffer in segmentBuffers) { - totalSamples += buffer.size - } - } - - val dataSize = totalSamples * 2L // 16-bit = 2 bytes per sample - - println("🎬 Writing WAV file: $totalSamples samples, ${duration}ms") - - // Write WAV file synchronously (simple approach that worked before) + synchronized(segmentBuffers) { for (buffer in segmentBuffers) totalSamples += buffer.size } + val dataSize = totalSamples * 2L val out = java.io.FileOutputStream(file) - - // Write WAV header writeWavHeader(out, dataSize, sampleRate, 1, 16) - - // Write all audio data synchronized(segmentBuffers) { for (buffer in segmentBuffers) { val byteBuffer = java.nio.ByteBuffer.allocate(buffer.size * 2) byteBuffer.order(java.nio.ByteOrder.LITTLE_ENDIAN) - for (sample in buffer) { - byteBuffer.putShort(sample) - } + for (sample in buffer) byteBuffer.putShort(sample) out.write(byteBuffer.array()) } } - - // Flush to ensure data is written (but don't sync - it can be slow/problematic) - out.flush() - out.close() - - println("✅ WAV file written: ${file.absolutePath}") - println("⏳ Cooldown active: ${cooldownPeriodMs}ms before next onset detection") - - // Send completion event with file URI + out.flush(); out.close() val uri = "file://${file.absolutePath}" - sendEvent("onSegmentComplete", mapOf( - "uri" to uri, - "startTime" to startTime.toDouble(), - "endTime" to endTime.toDouble(), - "duration" to duration.toDouble() - )) - + sendEvent("onSegmentComplete", mapOf("uri" to uri, "startTime" to startTime.toDouble(), "endTime" to endTime.toDouble(), "duration" to (endTime - startTime).toDouble())) promise.resolve(uri) - } else { - promise.resolve(null) - } - - segmentFile = null - segmentBuffers.clear() - - // Clear ring buffer only up to segment end time (+ small margin) - // This preserves audio that came after segment end (start of next segment) - val clearUpToTime = endTime + 50 // Clear up to 50ms after segment end - synchronized(ringBuffer) { - val initialCount = ringBuffer.size - ringBuffer.removeAll { entry -> - entry.timestamp <= clearUpToTime - } - val clearedCount = initialCount - ringBuffer.size - println("🗑️ Ring buffer: cleared $clearedCount entries up to segment end, preserved ${ringBuffer.size} entries") - } - + } else promise.resolve(null) + segmentFile = null; segmentBuffers.clear() } catch (e: Exception) { - promise.reject("STOP_SEGMENT_ERROR", "Failed to stop segment: ${e.message}", e) - segmentFile = null - segmentBuffers.clear() - // Clear ring buffer up to segment end time - val clearUpToTime = lastSegmentEndTime + 50 // Clear up to 50ms after segment end - synchronized(ringBuffer) { - ringBuffer.removeAll { entry -> - entry.timestamp <= clearUpToTime - } - } + promise.reject("STOP_SEGMENT_ERROR", "Failed to stop segment", e) + segmentFile = null; segmentBuffers.clear() } } -} \ No newline at end of file +} diff --git a/modules/microphone-energy/ios/MicrophoneEnergyModule.swift b/modules/microphone-energy/ios/MicrophoneEnergyModule.swift index 0b5d090c4..a9079c698 100644 --- a/modules/microphone-energy/ios/MicrophoneEnergyModule.swift +++ b/modules/microphone-energy/ios/MicrophoneEnergyModule.swift @@ -1,7 +1,17 @@ -import ExpoModulesCore +import ExpoModulesCore import AVFoundation import Foundation +/** + * New VAD Algorithm - Simple Threshold with Pre-Onset Buffer + * + * Key differences from old module (MicrophoneEnergyModule.old.swift): + * - No EMA smoothing - uses raw peak amplitude for immediate response + * - Pre-onset buffer tracking - continuously tracks a safe cut point + * - Three-state machine: IDLE -> ONSET_PENDING -> RECORDING + * - Async file writing - no blocking, no cooldown needed + * - Ring buffer always fills (not paused during recording) + */ public class MicrophoneEnergyModule: Module { private var audioEngine: AVAudioEngine? private var inputNode: AVAudioInputNode? @@ -9,206 +19,125 @@ public class MicrophoneEnergyModule: Module { private var audioConverter: AVAudioConverter? private var desiredFormat: AVAudioFormat? - // Ring buffer for capturing speech onset (200ms preroll) - // Store tuples of (buffer, timestamp) to enable time-based clearing private struct RingBufferEntry { let buffer: AVAudioPCMBuffer let timestamp: TimeInterval } private var ringBuffer: [RingBufferEntry] = [] - private let ringBufferMaxSize = 7 // ~200ms at typical buffer sizes + private let ringBufferMaxSize = 10 + private var isRecordingSegment = false private var segmentFile: URL? private var segmentStartTime: TimeInterval = 0 - - // Segment audio data collected in memory private var segmentBuffers: [AVAudioPCMBuffer] = [] - // Native VAD state - private var vadEnabled = false - private var vadThreshold: Float = 0.5 - private var vadOnsetMultiplier: Float = 0.25 - private var vadConfirmMultiplier: Float = 0.5 - private var vadSilenceDuration: Int = 300 // ms - private var vadMinSegmentDuration: Int = 500 // ms + private let sampleRate: Double = 44100 - // EMA smoothing - private let emaAlpha: Float = 0.3 - private var smoothedEnergy: Float = 0.0 + // VAD configuration + private var vadEnabled = false + private var vadThreshold: Float = 0.05 + private var vadOnsetMultiplier: Float = 0.1 + private var vadMaxOnsetDuration: Int = 250 + private var vadSilenceDuration: Int = 300 + private var vadMinSegmentDuration: Int = 500 + private var vadRewindHalfPause = true + private var vadMinActiveAudioDuration: Int = 250 // Discard clips with less active audio than this - // Schmitt trigger state - private var onsetDetected = false - private var onsetTime: TimeInterval = 0 - private var lastSpeechTime: TimeInterval = 0 + // VAD state machine + private var vadState = "IDLE" + private var preOnsetCutPoint: TimeInterval = 0 + private var lockedOnsetTime: TimeInterval = 0 + private var lastAboveThresholdTime: TimeInterval = 0 private var recordingStartTime: TimeInterval = 0 - private var lastSegmentEndTime: TimeInterval = 0 // Track when last segment ended - private let cooldownPeriodMs: TimeInterval = 500 // Cooldown after segment ends before detecting new onset - - private let sampleRate: Double = 44100 + private var activeAudioTime: TimeInterval = 0 // Cumulative time above threshold during recording + private var lastFrameTime: TimeInterval = 0 // For calculating delta time public func definition() -> ModuleDefinition { Name("MicrophoneEnergy") - Events("onEnergyResult", "onError", "onSegmentComplete", "onSegmentStart") - AsyncFunction("startEnergyDetection") { () -> Void in - try await self.startEnergyDetection() - } - - AsyncFunction("stopEnergyDetection") { () -> Void in - try await self.stopEnergyDetection() - } - - AsyncFunction("configureVAD") { (config: [String: Any?]) -> Void in - self.configureVAD(config: config) - } - - AsyncFunction("enableVAD") { () -> Void in - self.enableVAD() - } - - AsyncFunction("disableVAD") { () -> Void in - self.disableVAD() - } - - AsyncFunction("startSegment") { (options: [String: Any?]?) -> Void in - try await self.startSegment(options: options) - } - - AsyncFunction("stopSegment") { () -> String? in - return try await self.stopSegment() - } + AsyncFunction("startEnergyDetection") { () -> Void in try await self.startEnergyDetection() } + AsyncFunction("stopEnergyDetection") { () -> Void in try await self.stopEnergyDetection() } + AsyncFunction("configureVAD") { (config: [String: Any?]) -> Void in self.configureVAD(config: config) } + AsyncFunction("enableVAD") { () -> Void in self.enableVAD() } + AsyncFunction("disableVAD") { () -> Void in self.disableVAD() } + AsyncFunction("startSegment") { (options: [String: Any?]?) -> Void in try await self.startSegment(options: options) } + AsyncFunction("stopSegment") { () -> String? in return try await self.stopSegment() } } private func startEnergyDetection() async throws { - if isActive { - // Restart: stop current detection first - await stopEnergyDetectionInternal() - } + if isActive { await stopEnergyDetectionInternal() } - // Request microphone permission let audioSession = AVAudioSession.sharedInstance() - - // Check and request permission let permissionGranted = await withCheckedContinuation { continuation in - audioSession.requestRecordPermission { granted in - continuation.resume(returning: granted) - } + audioSession.requestRecordPermission { granted in continuation.resume(returning: granted) } } guard permissionGranted else { - let error = NSError(domain: "MicrophoneEnergy", code: 1, userInfo: [NSLocalizedDescriptionKey: "Microphone permission not granted"]) sendEvent("onError", ["message": "Microphone permission not granted"]) - throw error + throw NSError(domain: "MicrophoneEnergy", code: 1, userInfo: [NSLocalizedDescriptionKey: "Microphone permission not granted"]) } do { - // Configure audio session with optimized settings for recording - // Use .playAndRecord to allow both recording and playback if needed - // Allow Bluetooth devices for better microphone selection - // Use .defaultToSpeaker to route audio to speaker when not using headphones try audioSession.setCategory(.playAndRecord, mode: .default, options: [.defaultToSpeaker, .allowBluetooth, .allowBluetoothA2DP]) try audioSession.setActive(true) - // Create audio engine audioEngine = AVAudioEngine() - guard let engine = audioEngine else { - throw NSError(domain: "MicrophoneEnergy", code: 2, userInfo: [NSLocalizedDescriptionKey: "Failed to create audio engine"]) - } + guard let engine = audioEngine else { throw NSError(domain: "MicrophoneEnergy", code: 2, userInfo: nil) } inputNode = engine.inputNode - guard let input = inputNode else { - throw NSError(domain: "MicrophoneEnergy", code: 3, userInfo: [NSLocalizedDescriptionKey: "Failed to get input node"]) - } + guard let input = inputNode else { throw NSError(domain: "MicrophoneEnergy", code: 3, userInfo: nil) } let inputFormat = input.inputFormat(forBus: 0) - - // Convert to desired sample rate if needed desiredFormat = AVAudioFormat(commonFormat: .pcmFormatInt16, sampleRate: sampleRate, channels: 1, interleaved: false) - // Only create converter if formats differ if inputFormat.sampleRate != sampleRate || inputFormat.channelCount != 1 { - guard let desired = desiredFormat else { - throw NSError(domain: "MicrophoneEnergy", code: 4, userInfo: [NSLocalizedDescriptionKey: "Failed to create desired audio format"]) - } - guard let converter = AVAudioConverter(from: inputFormat, to: desired) else { - throw NSError(domain: "MicrophoneEnergy", code: 5, userInfo: [NSLocalizedDescriptionKey: "Failed to create audio converter"]) + if let desired = desiredFormat { + audioConverter = AVAudioConverter(from: inputFormat, to: desired) } - audioConverter = converter } - // Optimize buffer size for 44100 Hz sample rate - // At 44100 Hz, 2048 frames = ~46ms, which is good for low latency - // Keeping 2048 frames maintains good balance between latency and efficiency let bufferSize: AVAudioFrameCount = 2048 - - // Install tap on input node input.installTap(onBus: 0, bufferSize: bufferSize, format: inputFormat) { [weak self] (buffer, time) in guard let self = self else { return } + let timestamp = Date().timeIntervalSince1970 * 1000 - let timestamp = Date().timeIntervalSince1970 * 1000 // milliseconds - - // Convert buffer if needed if let converter = self.audioConverter, let desired = self.desiredFormat { - // Convert to desired format guard let convertedBuffer = AVAudioPCMBuffer(pcmFormat: desired, frameCapacity: buffer.frameLength) else { return } - - var error: NSError? var inputProvided = false let inputBlock: AVAudioConverterInputBlock = { _, outStatus in - if !inputProvided { - outStatus.pointee = .haveData - inputProvided = true - return buffer - } else { - outStatus.pointee = .noDataNow - return nil - } + if !inputProvided { outStatus.pointee = .haveData; inputProvided = true; return buffer } + else { outStatus.pointee = .noDataNow; return nil } } - + var error: NSError? converter.convert(to: convertedBuffer, error: &error, withInputFrom: inputBlock) - - if error == nil && convertedBuffer.frameLength > 0 { - self.processAudioData(buffer: convertedBuffer, timestamp: timestamp) - } + if error == nil && convertedBuffer.frameLength > 0 { self.processAudioData(buffer: convertedBuffer, timestamp: timestamp) } } else { - // Use buffer directly if no conversion needed self.processAudioData(buffer: buffer, timestamp: timestamp) } } - // Start audio engine try engine.start() isActive = true - } catch { - sendEvent("onError", ["message": "Failed to start energy detection: \(error.localizedDescription)"]) + sendEvent("onError", ["message": "Failed to start: \(error.localizedDescription)"]) throw error } } private func stopEnergyDetection() async throws { - if !isActive { - return - } - + if !isActive { return } await stopEnergyDetectionInternal() } private func stopEnergyDetectionInternal() async { isActive = false vadEnabled = false - onsetDetected = false + vadState = "IDLE" - // Stop any active segment if isRecordingSegment { - do { - _ = try await stopSegment() - } catch { - // Ignore errors when stopping segment during shutdown - } + do { _ = try await stopSegment() } catch {} } - // Remove tap and stop engine inputNode?.removeTap(onBus: 0) audioEngine?.stop() audioEngine = nil @@ -216,385 +145,292 @@ public class MicrophoneEnergyModule: Module { audioConverter = nil desiredFormat = nil - // Deactivate audio session - do { - try AVAudioSession.sharedInstance().setActive(false) - } catch { - // Ignore errors when deactivating session - } + do { try AVAudioSession.sharedInstance().setActive(false) } catch {} } private func configureVAD(config: [String: Any?]) { - if let threshold = config["threshold"] as? NSNumber { - vadThreshold = threshold.floatValue - } - if let silenceDuration = config["silenceDuration"] as? NSNumber { - vadSilenceDuration = silenceDuration.intValue - } - if let onsetMultiplier = config["onsetMultiplier"] as? NSNumber { - vadOnsetMultiplier = onsetMultiplier.floatValue - } - if let confirmMultiplier = config["confirmMultiplier"] as? NSNumber { - vadConfirmMultiplier = confirmMultiplier.floatValue - } - if let minSegmentDuration = config["minSegmentDuration"] as? NSNumber { - vadMinSegmentDuration = minSegmentDuration.intValue - } - - print("🎯 VAD configured: threshold=\(vadThreshold), silence=\(vadSilenceDuration)ms") + if let threshold = config["threshold"] as? NSNumber { vadThreshold = threshold.floatValue } + if let silenceDuration = config["silenceDuration"] as? NSNumber { vadSilenceDuration = silenceDuration.intValue } + if let minSegmentDuration = config["minSegmentDuration"] as? NSNumber { vadMinSegmentDuration = minSegmentDuration.intValue } + if let onsetMultiplier = config["onsetMultiplier"] as? NSNumber { vadOnsetMultiplier = onsetMultiplier.floatValue } + if let maxOnsetDuration = config["maxOnsetDuration"] as? NSNumber { vadMaxOnsetDuration = maxOnsetDuration.intValue } + if let rewindHalfPause = config["rewindHalfPause"] as? Bool { vadRewindHalfPause = rewindHalfPause } + if let minActiveAudioDuration = config["minActiveAudioDuration"] as? NSNumber { vadMinActiveAudioDuration = minActiveAudioDuration.intValue } } private func enableVAD() { vadEnabled = true - onsetDetected = false - smoothedEnergy = 0.0 - lastSegmentEndTime = 0 // Reset cooldown when VAD enabled - print("🎯 Native VAD enabled") + vadState = "IDLE" + preOnsetCutPoint = 0 + lockedOnsetTime = 0 + lastAboveThresholdTime = 0 } private func disableVAD() { vadEnabled = false - onsetDetected = false - - // Stop any active segment + vadState = "IDLE" if isRecordingSegment { - Task { - do { - _ = try await stopSegment() - } catch { - // Ignore errors - } - } + Task { do { _ = try await stopSegment() } catch {} } } - - print("🎯 Native VAD disabled") } private func processAudioData(buffer: AVAudioPCMBuffer, timestamp: TimeInterval) { let now = timestamp - - // Calculate peak amplitude (max absolute value) - matching expo-av's approach - // expo-av uses getMaxAmplitude() which returns peak, not RMS - // Handle both Int16 and Float32 formats - var peakAmplitude: Double = 0.0 let frameLength = Int(buffer.frameLength) + var peakAmplitude: Double = 0.0 if let int16Data = buffer.int16ChannelData { let channelDataPointer = int16Data.pointee for i in 0...size) } else if let srcFloat32 = buffer.floatChannelData, let dstFloat32 = bufferCopy.floatChannelData { memcpy(dstFloat32.pointee, srcFloat32.pointee, frameLength * MemoryLayout.size) } - // Manage ring buffer (always buffer when not recording segment) - if !isRecordingSegment { - ringBuffer.append(RingBufferEntry(buffer: bufferCopy, timestamp: now)) - if ringBuffer.count > ringBufferMaxSize { - ringBuffer.removeFirst() - } - } - - // If recording a segment, collect in memory - if isRecordingSegment { - segmentBuffers.append(bufferCopy) - } + ringBuffer.append(RingBufferEntry(buffer: bufferCopy, timestamp: now)) + if ringBuffer.count > ringBufferMaxSize { ringBuffer.removeFirst() } - // Native VAD logic (if enabled) - if vadEnabled { - handleNativeVAD(now: now) - } + if isRecordingSegment { segmentBuffers.append(bufferCopy) } + if vadEnabled { handleVAD(rawPeak: normalizedAmplitude, now: now) } - // Send energy level to JavaScript (for UI visualization) - sendEvent("onEnergyResult", [ - "energy": smoothedEnergy, - "timestamp": now - ]) + sendEvent("onEnergyResult", ["energy": normalizedAmplitude, "timestamp": now]) } - private func handleNativeVAD(now: TimeInterval) { + private func handleVAD(rawPeak: Float, now: TimeInterval) { let onsetThreshold = vadThreshold * vadOnsetMultiplier - let confirmThreshold = vadThreshold * vadConfirmMultiplier - // Update last speech time if above confirm threshold - if smoothedEnergy > confirmThreshold { - lastSpeechTime = now - } + if rawPeak > vadThreshold { lastAboveThresholdTime = now } - // State machine - if !isRecordingSegment && !onsetDetected { - // IDLE: Check for onset (with cooldown to prevent rapid re-triggers) - let timeSinceLastSegment = now - lastSegmentEndTime - if smoothedEnergy > onsetThreshold { - if timeSinceLastSegment >= cooldownPeriodMs || lastSegmentEndTime == 0 { - print("🎯 Native VAD: Onset detected (\(smoothedEnergy) > \(onsetThreshold))") - onsetDetected = true - onsetTime = now - } else { - // Still in cooldown period - ignore onset - print("⏳ Native VAD: Onset ignored (cooldown: \(Int(timeSinceLastSegment))ms/\(Int(cooldownPeriodMs))ms)") - } + switch vadState { + case "IDLE": + preOnsetCutPoint = max(0, now - TimeInterval(vadMaxOnsetDuration)) + if rawPeak > onsetThreshold { + vadState = "ONSET_PENDING" + lockedOnsetTime = preOnsetCutPoint + if rawPeak > vadThreshold { confirmAndStartRecording(now: now) } } - } else if !isRecordingSegment && onsetDetected { - // ONSET: Wait for confirmation or timeout - let timeSinceOnset = now - onsetTime - - if smoothedEnergy > confirmThreshold { - print("🎤 Native VAD: Speech CONFIRMED (\(smoothedEnergy) > \(confirmThreshold)) - auto-starting segment") - - // Start recording segment - onsetDetected = false - lastSpeechTime = now - recordingStartTime = now - - // Emit event to JS (for UI update - create pending card) - sendEvent("onSegmentStart", [:]) - - // Start segment with preroll - Task { - do { - try await startSegment(options: ["prerollMs": 200]) - } catch { - print("⚠️ Native VAD: Failed to start segment: \(error.localizedDescription)") - } - } - } else if timeSinceOnset > 300 { - // Timeout - false alarm - print("⚠️ Native VAD: Onset timeout - false alarm") - onsetDetected = false + case "ONSET_PENDING": + if now - lockedOnsetTime > TimeInterval(vadMaxOnsetDuration) { lockedOnsetTime = now - TimeInterval(vadMaxOnsetDuration) } + if rawPeak > vadThreshold { confirmAndStartRecording(now: now) } + else if rawPeak <= onsetThreshold { vadState = "IDLE" } + case "RECORDING": + // Track cumulative time above threshold + let deltaMs = now - lastFrameTime + if rawPeak > vadThreshold { + activeAudioTime += deltaMs } - } else if isRecordingSegment { - // RECORDING: Monitor for silence - let silenceMs = now - lastSpeechTime + lastFrameTime = now + + let silenceMs = now - lastAboveThresholdTime let durationMs = now - recordingStartTime + if silenceMs >= TimeInterval(vadSilenceDuration) && durationMs >= TimeInterval(vadMinSegmentDuration) { + stopRecordingAsync() + } + default: break + } + } + + private func confirmAndStartRecording(now: TimeInterval) { + vadState = "RECORDING" + lastAboveThresholdTime = now + recordingStartTime = now + activeAudioTime = 0 // Reset active audio tracking + lastFrameTime = now + sendEvent("onSegmentStart", [:]) + let prerollMs = Int(now - lockedOnsetTime) + Task { do { try await startSegment(options: ["prerollMs": prerollMs]) } catch {} } + } + + private func stopRecordingAsync() { + guard isRecordingSegment else { return } + isRecordingSegment = false + vadState = "IDLE" + + // Check if enough active audio - discard transients/short sounds + if activeAudioTime < TimeInterval(vadMinActiveAudioDuration) { + print("VAD: Discarding segment - only \(Int(activeAudioTime))ms of active audio (min: \(vadMinActiveAudioDuration)ms)") + segmentBuffers.removeAll() + if let fileURL = segmentFile { + try? FileManager.default.removeItem(at: fileURL) // Clean up temp file + } + segmentFile = nil - if silenceMs >= Double(vadSilenceDuration) && durationMs >= Double(vadMinSegmentDuration) { - print("💤 Native VAD: \(Int(silenceMs))ms silence - auto-stopping segment") - - // Stop segment (will emit onSegmentComplete) - Task { - do { - _ = try await stopSegment() - } catch { - print("⚠️ Native VAD: Failed to stop segment: \(error.localizedDescription)") - } + // Emit empty URI to notify JS that recording stopped but was discarded + self.sendEvent("onSegmentComplete", ["uri": "", "duration": 0]) + return + } + + let buffersToWrite = segmentBuffers + let fileToWrite = segmentFile + let startTime = segmentStartTime + let endTime = Date().timeIntervalSince1970 * 1000 + let rewindMs = vadRewindHalfPause ? vadSilenceDuration / 2 : 0 + + segmentBuffers.removeAll() + segmentFile = nil + + DispatchQueue.global(qos: .background).async { [weak self] in + guard let self = self, let fileURL = fileToWrite else { return } + do { + try self.writeWavFileAsync(fileURL: fileURL, buffers: buffersToWrite, rewindMs: rewindMs) + let pathString = fileURL.path + let normalizedPath = pathString.hasPrefix("/") ? pathString : "/\(pathString)" + let uri = "file://\(normalizedPath)" + let duration = endTime - startTime - Double(rewindMs) + DispatchQueue.main.async { + self.sendEvent("onSegmentComplete", ["uri": uri, "startTime": startTime, "endTime": endTime - Double(rewindMs), "duration": duration]) } - } + } catch {} } } - private func startSegment(options: [String: Any?]?) async throws { - guard isActive else { - throw NSError(domain: "MicrophoneEnergy", code: 4, userInfo: [NSLocalizedDescriptionKey: "Energy detection not active"]) + private func writeWavFileAsync(fileURL: URL, buffers: [AVAudioPCMBuffer], rewindMs: Int) throws { + let samplesToTrim = Int(sampleRate) * rewindMs / 1000 + var totalSamples = 0 + for buffer in buffers { totalSamples += Int(buffer.frameLength) } + let finalSamples = max(0, totalSamples - samplesToTrim) + let dataSize = finalSamples * 2 + + if !FileManager.default.fileExists(atPath: fileURL.path) { + FileManager.default.createFile(atPath: fileURL.path, contents: nil, attributes: nil) } - if isRecordingSegment { - print("⚠️ Segment already recording, ignoring duplicate start") - return + let fileHandle = try FileHandle(forWritingTo: fileURL) + defer { try? fileHandle.close() } + try fileHandle.truncate(atOffset: 0) + try writeWAVHeader(fileHandle: fileHandle, dataSize: Int64(dataSize), sampleRate: Int(sampleRate), channels: 1, bitsPerSample: 16) + + var samplesWritten = 0 + for buffer in buffers { + let frameLength = Int(buffer.frameLength) + let samplesToWrite = min(frameLength, finalSamples - samplesWritten) + if samplesToWrite <= 0 { break } + + if let int16Data = buffer.int16ChannelData { + let audioData = Data(bytes: int16Data.pointee, count: samplesToWrite * MemoryLayout.size) + try fileHandle.write(contentsOf: audioData) + } else if let float32Data = buffer.floatChannelData { + var int16Samples = [Int16](repeating: 0, count: samplesToWrite) + for i in 0...size) + try fileHandle.write(contentsOf: audioData) + } + samplesWritten += samplesToWrite } + try fileHandle.synchronize() + } + + private func startSegment(options: [String: Any?]?) async throws { + guard isActive else { throw NSError(domain: "MicrophoneEnergy", code: 4, userInfo: nil) } + if isRecordingSegment { return } - // Get preroll duration (default 200ms) let prerollMs = (options?["prerollMs"] as? NSNumber)?.intValue ?? 200 - - // Create temp file for segment (WAV format for compatibility) let tempDir = FileManager.default.temporaryDirectory let fileName = "segment_\(UUID().uuidString).wav" let fileURL = tempDir.appendingPathComponent(fileName) segmentFile = fileURL - segmentStartTime = Date().timeIntervalSince1970 * 1000 // milliseconds + segmentStartTime = Date().timeIntervalSince1970 * 1000 - // Copy preroll from ring buffer - let samplesPerMs = sampleRate / 1000.0 - let typicalBufferSize = 2048.0 - let maxPrerollBuffers = Int(Double(prerollMs) / (typicalBufferSize / samplesPerMs)) + let maxPrerollBuffers = Int(Double(prerollMs) / (2048.0 / (sampleRate / 1000.0))) let buffersToWrite = min(ringBuffer.count, maxPrerollBuffers) segmentBuffers.removeAll() - // Copy buffers (not entries) from ring buffer - for entry in ringBuffer.suffix(buffersToWrite) { - segmentBuffers.append(entry.buffer) - } - - // Don't clear ring buffer here - it will be cleared on segment end up to that point - print("📼 Preroll: \(buffersToWrite) chunks (~\(prerollMs)ms)") + for entry in ringBuffer.suffix(buffersToWrite) { segmentBuffers.append(entry.buffer) } isRecordingSegment = true - print("🎬 Segment recording started with preroll") } private func stopSegment() async throws -> String? { - guard isRecordingSegment else { - print("⚠️ No segment recording active") - return nil - } - + guard isRecordingSegment else { return nil } isRecordingSegment = false - - // Record when segment ended for cooldown logic - lastSegmentEndTime = Date().timeIntervalSince1970 * 1000 // milliseconds + vadState = "IDLE" guard let fileURL = segmentFile else { segmentBuffers.removeAll() - // Clear ring buffer up to segment end time - let clearUpToTime = lastSegmentEndTime + 50 // Clear up to 50ms after segment end - ringBuffer.removeAll { entry in - entry.timestamp <= clearUpToTime - } return nil } let startTime = segmentStartTime - let endTime = lastSegmentEndTime + let endTime = Date().timeIntervalSince1970 * 1000 let duration = endTime - startTime - // Calculate total samples var totalSamples = 0 - for buffer in segmentBuffers { - totalSamples += Int(buffer.frameLength) - } - - let dataSize = totalSamples * 2 // 16-bit = 2 bytes per sample + for buffer in segmentBuffers { totalSamples += Int(buffer.frameLength) } + let dataSize = totalSamples * 2 - print("🎬 Writing WAV file: \(totalSamples) samples, \(Int(duration))ms") - - // Create file if it doesn't exist if !FileManager.default.fileExists(atPath: fileURL.path) { FileManager.default.createFile(atPath: fileURL.path, contents: nil, attributes: nil) } - // Write WAV file let fileHandle = try FileHandle(forWritingTo: fileURL) defer { try? fileHandle.close() } - - // Truncate file to start fresh try fileHandle.truncate(atOffset: 0) - - // Write WAV header try writeWAVHeader(fileHandle: fileHandle, dataSize: Int64(dataSize), sampleRate: Int(sampleRate), channels: 1, bitsPerSample: 16) - // Write all audio data for buffer in segmentBuffers { let frameLength = Int(buffer.frameLength) - if let int16Data = buffer.int16ChannelData { - let channelDataPointer = int16Data.pointee - let audioData = Data(bytes: channelDataPointer, count: frameLength * MemoryLayout.size) + let audioData = Data(bytes: int16Data.pointee, count: frameLength * MemoryLayout.size) try fileHandle.write(contentsOf: audioData) } else if let float32Data = buffer.floatChannelData { - // Convert Float32 to Int16 - let channelDataPointer = float32Data.pointee var int16Samples = [Int16](repeating: 0, count: frameLength) for i in 0...size) try fileHandle.write(contentsOf: audioData) } } - try fileHandle.synchronize() - print("✅ WAV file written: \(fileURL.path)") - print("⏳ Cooldown active: \(Int(cooldownPeriodMs))ms before next onset detection") - - // Send completion event with file URI - // fileURL.path returns absolute path like "/Users/.../tmp/segment.wav" - // We need "file:///Users/..." (3 slashes total: file:// + /Users/...) let pathString = fileURL.path - // Ensure path starts with / for absolute paths let normalizedPath = pathString.hasPrefix("/") ? pathString : "/\(pathString)" - // Create file:// URI with exactly 3 slashes total let uri = "file://\(normalizedPath)" - sendEvent("onSegmentComplete", [ - "uri": uri, - "startTime": startTime, - "endTime": endTime, - "duration": duration - ]) + sendEvent("onSegmentComplete", ["uri": uri, "startTime": startTime, "endTime": endTime, "duration": duration]) segmentFile = nil segmentBuffers.removeAll() - - // Clear ring buffer only up to segment end time (+ small margin) - // This preserves audio that came after segment end (start of next segment) - let clearUpToTime = endTime + 50 // Clear up to 50ms after segment end - let initialCount = ringBuffer.count - ringBuffer.removeAll { entry in - entry.timestamp <= clearUpToTime - } - let clearedCount = initialCount - ringBuffer.count - print("🗑️ Ring buffer: cleared \(clearedCount) entries up to segment end, preserved \(ringBuffer.count) entries") - return uri } private func writeWAVHeader(fileHandle: FileHandle, dataSize: Int64, sampleRate: Int, channels: Int, bitsPerSample: Int) throws { var header = Data() - - // RIFF header header.append("RIFF".data(using: .ascii)!) header.append(contentsOf: withUnsafeBytes(of: UInt32(36 + dataSize).littleEndian) { Data($0) }) header.append("WAVE".data(using: .ascii)!) - - // fmt chunk header.append("fmt ".data(using: .ascii)!) - header.append(contentsOf: withUnsafeBytes(of: UInt32(16).littleEndian) { Data($0) }) // fmt chunk size - header.append(contentsOf: withUnsafeBytes(of: UInt16(1).littleEndian) { Data($0) }) // Audio format (1 = PCM) + header.append(contentsOf: withUnsafeBytes(of: UInt32(16).littleEndian) { Data($0) }) + header.append(contentsOf: withUnsafeBytes(of: UInt16(1).littleEndian) { Data($0) }) header.append(contentsOf: withUnsafeBytes(of: UInt16(channels).littleEndian) { Data($0) }) header.append(contentsOf: withUnsafeBytes(of: UInt32(sampleRate).littleEndian) { Data($0) }) - header.append(contentsOf: withUnsafeBytes(of: UInt32(sampleRate * channels * bitsPerSample / 8).littleEndian) { Data($0) }) // Byte rate - header.append(contentsOf: withUnsafeBytes(of: UInt16(channels * bitsPerSample / 8).littleEndian) { Data($0) }) // Block align + header.append(contentsOf: withUnsafeBytes(of: UInt32(sampleRate * channels * bitsPerSample / 8).littleEndian) { Data($0) }) + header.append(contentsOf: withUnsafeBytes(of: UInt16(channels * bitsPerSample / 8).littleEndian) { Data($0) }) header.append(contentsOf: withUnsafeBytes(of: UInt16(bitsPerSample).littleEndian) { Data($0) }) - - // data chunk header.append("data".data(using: .ascii)!) header.append(contentsOf: withUnsafeBytes(of: UInt32(dataSize).littleEndian) { Data($0) }) - try fileHandle.write(contentsOf: header) } } diff --git a/modules/microphone-energy/src/MicrophoneEnergyModule.ts b/modules/microphone-energy/src/MicrophoneEnergyModule.ts index 68446e8ec..e436e9486 100644 --- a/modules/microphone-energy/src/MicrophoneEnergyModule.ts +++ b/modules/microphone-energy/src/MicrophoneEnergyModule.ts @@ -13,6 +13,9 @@ export type VADConfig = { onsetMultiplier?: number; confirmMultiplier?: number; minSegmentDuration?: number; + maxOnsetDuration?: number; + rewindHalfPause?: boolean; + minActiveAudioDuration?: number; }; // eslint-disable-next-line @typescript-eslint/consistent-type-definitions diff --git a/package-lock.json b/package-lock.json index dfbc1e85d..a70d0bf69 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,6 +26,7 @@ "@quidone/react-native-wheel-picker": "^1.6.1", "@react-native-async-storage/async-storage": "2.1.2", "@react-native-community/netinfo": "11.4.1", + "@react-native-community/slider": "^5.1.1", "@react-native-documents/picker": "^10.1.5", "@react-navigation/bottom-tabs": "^7.3.10", "@react-navigation/native": "^7.1.6", @@ -126,6 +127,7 @@ "@types/react-test-renderer": "^19.0.0", "babel-plugin-inline-import": "^3.0.0", "babel-preset-expo": "^54.0.6", + "cross-env": "^10.1.0", "default-gateway": "^7.2.2", "dotenv": "^16.4.7", "drizzle-kit": "^0.31.4", @@ -2618,7 +2620,6 @@ "version": "1.5.0", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.5.0.tgz", "integrity": "sha512-sbP8GzB1WDzacS8fgNPpHlp6C9VZe+SJP3F90W9rLemaQj2PzIuTEl1qDOYQf58YIpyjViI24y9aPWCjEzY2cg==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -2630,7 +2631,6 @@ "version": "1.5.0", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.5.0.tgz", "integrity": "sha512-97/BJ3iXHww3djw6hYIfErCZFee7qCtrneuLa20UXFCOTCfBM2cvQHjWJ2EG0s0MtdNwInarqCTz35i4wWXHsQ==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -2641,7 +2641,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.1.0.tgz", "integrity": "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -2665,6 +2664,13 @@ "license": "MIT", "optional": true }, + "node_modules/@epic-web/invariant": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@epic-web/invariant/-/invariant-1.0.0.tgz", + "integrity": "sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==", + "dev": true, + "license": "MIT" + }, "node_modules/@esbuild-kit/core-utils": { "version": "3.3.2", "resolved": "https://registry.npmjs.org/@esbuild-kit/core-utils/-/core-utils-3.3.2.tgz", @@ -2677,6 +2683,57 @@ "source-map-support": "^0.5.21" } }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/android-arm": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.18.20.tgz", + "integrity": "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/android-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.18.20.tgz", + "integrity": "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/android-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.18.20.tgz", + "integrity": "sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/darwin-arm64": { "version": "0.18.20", "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.18.20.tgz", @@ -2694,68 +2751,799 @@ "node": ">=12" } }, - "node_modules/@esbuild-kit/core-utils/node_modules/esbuild": { + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/darwin-x64": { "version": "0.18.20", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.18.20.tgz", - "integrity": "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.18.20.tgz", + "integrity": "sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ==", + "cpu": [ + "x64" + ], "dev": true, - "hasInstallScript": true, "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, + "optional": true, + "os": [ + "darwin" + ], "engines": { "node": ">=12" - }, - "optionalDependencies": { - "@esbuild/android-arm": "0.18.20", - "@esbuild/android-arm64": "0.18.20", - "@esbuild/android-x64": "0.18.20", - "@esbuild/darwin-arm64": "0.18.20", - "@esbuild/darwin-x64": "0.18.20", - "@esbuild/freebsd-arm64": "0.18.20", - "@esbuild/freebsd-x64": "0.18.20", - "@esbuild/linux-arm": "0.18.20", - "@esbuild/linux-arm64": "0.18.20", - "@esbuild/linux-ia32": "0.18.20", - "@esbuild/linux-loong64": "0.18.20", - "@esbuild/linux-mips64el": "0.18.20", - "@esbuild/linux-ppc64": "0.18.20", - "@esbuild/linux-riscv64": "0.18.20", - "@esbuild/linux-s390x": "0.18.20", - "@esbuild/linux-x64": "0.18.20", - "@esbuild/netbsd-x64": "0.18.20", - "@esbuild/openbsd-x64": "0.18.20", - "@esbuild/sunos-x64": "0.18.20", - "@esbuild/win32-arm64": "0.18.20", - "@esbuild/win32-ia32": "0.18.20", - "@esbuild/win32-x64": "0.18.20" } }, - "node_modules/@esbuild-kit/esm-loader": { - "version": "2.6.5", - "resolved": "https://registry.npmjs.org/@esbuild-kit/esm-loader/-/esm-loader-2.6.5.tgz", - "integrity": "sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA==", - "deprecated": "Merged into tsx: https://tsx.is", + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/freebsd-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.18.20.tgz", + "integrity": "sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/freebsd-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.18.20.tgz", + "integrity": "sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-arm": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.18.20.tgz", + "integrity": "sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.18.20.tgz", + "integrity": "sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-ia32": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.18.20.tgz", + "integrity": "sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-loong64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.18.20.tgz", + "integrity": "sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-mips64el": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.18.20.tgz", + "integrity": "sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-ppc64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.18.20.tgz", + "integrity": "sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-riscv64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.18.20.tgz", + "integrity": "sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-s390x": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.18.20.tgz", + "integrity": "sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.18.20.tgz", + "integrity": "sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/netbsd-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.18.20.tgz", + "integrity": "sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/openbsd-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.18.20.tgz", + "integrity": "sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/sunos-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.18.20.tgz", + "integrity": "sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/win32-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.18.20.tgz", + "integrity": "sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/win32-ia32": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.18.20.tgz", + "integrity": "sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/win32-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.18.20.tgz", + "integrity": "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/esbuild": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.18.20.tgz", + "integrity": "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/android-arm": "0.18.20", + "@esbuild/android-arm64": "0.18.20", + "@esbuild/android-x64": "0.18.20", + "@esbuild/darwin-arm64": "0.18.20", + "@esbuild/darwin-x64": "0.18.20", + "@esbuild/freebsd-arm64": "0.18.20", + "@esbuild/freebsd-x64": "0.18.20", + "@esbuild/linux-arm": "0.18.20", + "@esbuild/linux-arm64": "0.18.20", + "@esbuild/linux-ia32": "0.18.20", + "@esbuild/linux-loong64": "0.18.20", + "@esbuild/linux-mips64el": "0.18.20", + "@esbuild/linux-ppc64": "0.18.20", + "@esbuild/linux-riscv64": "0.18.20", + "@esbuild/linux-s390x": "0.18.20", + "@esbuild/linux-x64": "0.18.20", + "@esbuild/netbsd-x64": "0.18.20", + "@esbuild/openbsd-x64": "0.18.20", + "@esbuild/sunos-x64": "0.18.20", + "@esbuild/win32-arm64": "0.18.20", + "@esbuild/win32-ia32": "0.18.20", + "@esbuild/win32-x64": "0.18.20" + } + }, + "node_modules/@esbuild-kit/esm-loader": { + "version": "2.6.5", + "resolved": "https://registry.npmjs.org/@esbuild-kit/esm-loader/-/esm-loader-2.6.5.tgz", + "integrity": "sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA==", + "deprecated": "Merged into tsx: https://tsx.is", + "dev": true, + "license": "MIT", + "dependencies": { + "@esbuild-kit/core-utils": "^3.3.2", + "get-tsconfig": "^4.7.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.9.tgz", + "integrity": "sha512-OaGtL73Jck6pBKjNIe24BnFE6agGl+6KxDtTfHhy1HmhthfKouEcOhqpSL64K4/0WCtbKFLOdzD/44cJ4k9opA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.9.tgz", + "integrity": "sha512-5WNI1DaMtxQ7t7B6xa572XMXpHAaI/9Hnhk8lcxF4zVN4xstUgTlvuGDorBguKEnZO70qwEcLpfifMLoxiPqHQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.9.tgz", + "integrity": "sha512-IDrddSmpSv51ftWslJMvl3Q2ZT98fUSL2/rlUXuVqRXHCs5EUF1/f+jbjF5+NG9UffUDMCiTyh8iec7u8RlTLg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.9.tgz", + "integrity": "sha512-I853iMZ1hWZdNllhVZKm34f4wErd4lMyeV7BLzEExGEIZYsOzqDWDf+y082izYUE8gtJnYHdeDpN/6tUdwvfiw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.9.tgz", + "integrity": "sha512-XIpIDMAjOELi/9PB30vEbVMs3GV1v2zkkPnuyRRURbhqjyzIINwj+nbQATh4H9GxUgH1kFsEyQMxwiLFKUS6Rg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.9.tgz", + "integrity": "sha512-jhHfBzjYTA1IQu8VyrjCX4ApJDnH+ez+IYVEoJHeqJm9VhG9Dh2BYaJritkYK3vMaXrf7Ogr/0MQ8/MeIefsPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.9.tgz", + "integrity": "sha512-z93DmbnY6fX9+KdD4Ue/H6sYs+bhFQJNCPZsi4XWJoYblUqT06MQUdBCpcSfuiN72AbqeBFu5LVQTjfXDE2A6Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.9.tgz", + "integrity": "sha512-mrKX6H/vOyo5v71YfXWJxLVxgy1kyt1MQaD8wZJgJfG4gq4DpQGpgTB74e5yBeQdyMTbgxp0YtNj7NuHN0PoZg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.9.tgz", + "integrity": "sha512-HBU2Xv78SMgaydBmdor38lg8YDnFKSARg1Q6AT0/y2ezUAKiZvc211RDFHlEZRFNRVhcMamiToo7bDx3VEOYQw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.9.tgz", + "integrity": "sha512-BlB7bIcLT3G26urh5Dmse7fiLmLXnRlopw4s8DalgZ8ef79Jj4aUcYbk90g8iCa2467HX8SAIidbL7gsqXHdRw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.9.tgz", + "integrity": "sha512-e7S3MOJPZGp2QW6AK6+Ly81rC7oOSerQ+P8L0ta4FhVi+/j/v2yZzx5CqqDaWjtPFfYz21Vi1S0auHrap3Ma3A==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.9.tgz", + "integrity": "sha512-Sbe10Bnn0oUAB2AalYztvGcK+o6YFFA/9829PhOCUS9vkJElXGdphz0A3DbMdP8gmKkqPmPcMJmJOrI3VYB1JQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.9.tgz", + "integrity": "sha512-YcM5br0mVyZw2jcQeLIkhWtKPeVfAerES5PvOzaDxVtIyZ2NUBZKNLjC5z3/fUlDgT6w89VsxP2qzNipOaaDyA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.9.tgz", + "integrity": "sha512-++0HQvasdo20JytyDpFvQtNrEsAgNG2CY1CLMwGXfFTKGBGQT3bOeLSYE2l1fYdvML5KUuwn9Z8L1EWe2tzs1w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.9.tgz", + "integrity": "sha512-uNIBa279Y3fkjV+2cUjx36xkx7eSjb8IvnL01eXUKXez/CBHNRw5ekCGMPM0BcmqBxBcdgUWuUXmVWwm4CH9kg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.9.tgz", + "integrity": "sha512-Mfiphvp3MjC/lctb+7D287Xw1DGzqJPb/J2aHHcHxflUo+8tmN/6d4k6I2yFR7BVo5/g7x2Monq4+Yew0EHRIA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.9.tgz", + "integrity": "sha512-iSwByxzRe48YVkmpbgoxVzn76BXjlYFXC7NvLYq+b+kDjyyk30J0JY47DIn8z1MO3K0oSl9fZoRmZPQI4Hklzg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.9.tgz", + "integrity": "sha512-9jNJl6FqaUG+COdQMjSCGW4QiMHH88xWbvZ+kRVblZsWrkXlABuGdFJ1E9L7HK+T0Yqd4akKNa/lO0+jDxQD4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.9.tgz", + "integrity": "sha512-RLLdkflmqRG8KanPGOU7Rpg829ZHu8nFy5Pqdi9U01VYtG9Y0zOG6Vr2z4/S+/3zIyOxiK6cCeYNWOFR9QP87g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.9.tgz", + "integrity": "sha512-YaFBlPGeDasft5IIM+CQAhJAqS3St3nJzDEgsgFixcfZeyGPCd6eJBWzke5piZuZ7CtL656eOSYKk4Ls2C0FRQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.9.tgz", + "integrity": "sha512-1MkgTCuvMGWuqVtAvkpkXFmtL8XhWy+j4jaSO2wxfJtilVCi0ZE37b8uOdMItIHz4I6z1bWWtEX4CJwcKYLcuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.9.tgz", + "integrity": "sha512-4Xd0xNiMVXKh6Fa7HEJQbrpP3m3DDn43jKxMjxLLRjWnRsfxjORYJlXPO4JNcXtOyfajXorRKY9NkOpTHptErg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.9.tgz", + "integrity": "sha512-WjH4s6hzo00nNezhp3wFIAfmGZ8U7KtrJNlFMRKxiI9mxEK1scOMAaa9i4crUtu+tBr+0IN6JCuAcSBJZfnphw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.9.tgz", + "integrity": "sha512-mGFrVJHmZiRqmP8xFOc6b84/7xa5y5YvR1x8djzXpJBSv/UsNK6aqec+6JDjConTgvvQefdGhFDAs2DLAds6gQ==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@esbuild-kit/core-utils": "^3.3.2", - "get-tsconfig": "^4.7.0" + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" } }, - "node_modules/@esbuild/darwin-arm64": { + "node_modules/@esbuild/win32-ia32": { "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.9.tgz", - "integrity": "sha512-XIpIDMAjOELi/9PB30vEbVMs3GV1v2zkkPnuyRRURbhqjyzIINwj+nbQATh4H9GxUgH1kFsEyQMxwiLFKUS6Rg==", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.9.tgz", + "integrity": "sha512-b33gLVU2k11nVx1OhX3C8QQP6UHQK4ZtN56oFWvVXvz2VkDoe6fbG8TOgHFxEvqeqohmRnIHe5A1+HADk4OQww==", "cpu": [ - "arm64" + "ia32" ], "dev": true, "license": "MIT", "optional": true, "os": [ - "darwin" + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.9.tgz", + "integrity": "sha512-PPOl1mi6lpLNQxnGoyAfschAodRFYXJ+9fs6WHXz7CSWKbOqiMZsubC+BQsVKuul+3vKLuwTHsS2c2y9EoKwxQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" ], "engines": { "node": ">=18" @@ -4142,108 +4930,422 @@ } } }, - "node_modules/@gorhom/portal": { - "version": "1.0.14", - "resolved": "https://registry.npmjs.org/@gorhom/portal/-/portal-1.0.14.tgz", - "integrity": "sha512-MXyL4xvCjmgaORr/rtryDNFy3kU4qUbKlwtQqqsygd0xX3mhKjOLn6mQK8wfu0RkoE0pBE0nAasRoHua+/QZ7A==", - "license": "MIT", - "dependencies": { - "nanoid": "^3.3.1" - }, - "peerDependencies": { - "react": "*", - "react-native": "*" + "node_modules/@gorhom/portal": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/@gorhom/portal/-/portal-1.0.14.tgz", + "integrity": "sha512-MXyL4xvCjmgaORr/rtryDNFy3kU4qUbKlwtQqqsygd0xX3mhKjOLn6mQK8wfu0RkoE0pBE0nAasRoHua+/QZ7A==", + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.1" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, + "node_modules/@hapi/hoek": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz", + "integrity": "sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ==", + "devOptional": true, + "license": "BSD-3-Clause" + }, + "node_modules/@hapi/topo": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@hapi/topo/-/topo-5.1.0.tgz", + "integrity": "sha512-foQZKJig7Ob0BMAYBfcJk8d77QtOe7Wo4ox7ff1lQYoNNAb6jwcY1ncdoy2e9wQZzvNy7ODZCYJkK8kzmcAnAg==", + "devOptional": true, + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^9.0.0" + } + }, + "node_modules/@hookform/resolvers": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-5.2.1.tgz", + "integrity": "sha512-u0+6X58gkjMcxur1wRWokA7XsiiBJ6aK17aPZxhkoYiK5J+HcTx0Vhu9ovXe6H+dVpO6cjrn2FkJTryXEMlryQ==", + "license": "MIT", + "dependencies": { + "@standard-schema/utils": "^0.3.0" + }, + "peerDependencies": { + "react-hook-form": "^7.55.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.2", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.2.tgz", + "integrity": "sha512-OfXHZPppddivUJnqyKoi5YVeHRkkNE2zUFT2gbpKxp/JZCFYEYubnMg+gOp6lWfasPrTS+KPosKqdI+ELYVDtg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.1.0" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.2", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.2.tgz", + "integrity": "sha512-dYvWqmjU9VxqXmjEtjmvHnGqF8GrVjM2Epj9rJ6BUIXvk8slvNDJbhGFvIoXzkDhrJC2jUxNLz/GUjjvSzfw+g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.1.0" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.1.0.tgz", + "integrity": "sha512-HZ/JUmPwrJSoM4DIQPv/BfNh9yrOA8tlBbqbLz4JZ5uew2+o22Ik+tHQJcih7QJuSa0zo5coHTfD5J8inqj9DA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.1.0.tgz", + "integrity": "sha512-Xzc2ToEmHN+hfvsl9wja0RlnXEgpKNmftriQp6XzY/RaSfwD9th+MSh0WQKzUreLKKINb3afirxW7A0fz2YWuQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.1.0.tgz", + "integrity": "sha512-s8BAd0lwUIvYCJyRdFqvsj+BJIpDBSxs6ivrOPm/R7piTs5UIwY5OjXrP2bqXC9/moGsyRa37eYWYCOGVXxVrA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.1.0.tgz", + "integrity": "sha512-IVfGJa7gjChDET1dK9SekxFFdflarnUB8PwW8aGwEoF3oAsSDuNUTYS+SKDOyOJxQyDC1aPFMuRYLoDInyV9Ew==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.1.0.tgz", + "integrity": "sha512-tiXxFZFbhnkWE2LA8oQj7KYR+bWBkiV2nilRldT7bqoEZ4HiDOcePr9wVDAZPi/Id5fT1oY9iGnDq20cwUz8lQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.1.0.tgz", + "integrity": "sha512-xukSwvhguw7COyzvmjydRb3x/09+21HykyapcZchiCUkTThEQEOMtBj9UhkaBRLuBrgLFzQ2wbxdeCCJW/jgJA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.1.0.tgz", + "integrity": "sha512-yRj2+reB8iMg9W5sULM3S74jVS7zqSzHG3Ol/twnAAkAhnGQnpjj6e4ayUz7V+FpKypwgs82xbRdYtchTTUB+Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" } }, - "node_modules/@hapi/hoek": { - "version": "9.3.0", - "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz", - "integrity": "sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ==", - "devOptional": true, - "license": "BSD-3-Clause" + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.1.0.tgz", + "integrity": "sha512-jYZdG+whg0MDK+q2COKbYidaqW/WTz0cc1E+tMAusiDygrM4ypmSCjOJPmFTvHHJ8j/6cAGyeDWZOsK06tP33w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } }, - "node_modules/@hapi/topo": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/@hapi/topo/-/topo-5.1.0.tgz", - "integrity": "sha512-foQZKJig7Ob0BMAYBfcJk8d77QtOe7Wo4ox7ff1lQYoNNAb6jwcY1ncdoy2e9wQZzvNy7ODZCYJkK8kzmcAnAg==", - "devOptional": true, - "license": "BSD-3-Clause", - "dependencies": { - "@hapi/hoek": "^9.0.0" + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.1.0.tgz", + "integrity": "sha512-wK7SBdwrAiycjXdkPnGCPLjYb9lD4l6Ze2gSdAGVZrEL05AOUJESWU2lhlC+Ffn5/G+VKuSm6zzbQSzFX/P65A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" } }, - "node_modules/@hookform/resolvers": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-5.2.1.tgz", - "integrity": "sha512-u0+6X58gkjMcxur1wRWokA7XsiiBJ6aK17aPZxhkoYiK5J+HcTx0Vhu9ovXe6H+dVpO6cjrn2FkJTryXEMlryQ==", - "license": "MIT", - "dependencies": { - "@standard-schema/utils": "^0.3.0" + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.2.tgz", + "integrity": "sha512-0DZzkvuEOqQUP9mo2kjjKNok5AmnOr1jB2XYjkaoNRwpAYMDzRmAqUIa1nRi58S2WswqSfPOWLNOr0FDT3H5RQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, - "peerDependencies": { - "react-hook-form": "^7.55.0" + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.1.0" } }, - "node_modules/@humanfs/core": { - "version": "0.19.1", - "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", - "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.2.tgz", + "integrity": "sha512-D8n8wgWmPDakc83LORcfJepdOSN6MvWNzzz2ux0MnIbOqdieRZwVYY32zxVx+IFUT8er5KPcyU3XXsn+GzG/0Q==", + "cpu": [ + "arm64" + ], + "dev": true, "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=18.18.0" + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.1.0" } }, - "node_modules/@humanfs/node": { - "version": "0.16.7", - "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", - "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.2.tgz", + "integrity": "sha512-EGZ1xwhBI7dNISwxjChqBGELCWMGDvmxZXKjQRuqMrakhO8QoMgqCrdjnAqJq/CScxfRn+Bb7suXBElKQpPDiw==", + "cpu": [ + "s390x" + ], + "dev": true, "license": "Apache-2.0", - "dependencies": { - "@humanfs/core": "^0.19.1", - "@humanwhocodes/retry": "^0.4.0" - }, + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=18.18.0" + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.1.0" } }, - "node_modules/@humanwhocodes/module-importer": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", - "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.2.tgz", + "integrity": "sha512-sD7J+h5nFLMMmOXYH4DD9UtSNBD05tWSSdWAcEyzqW8Cn5UxXvsHAxmxSesYUsTOBmUnjtxghKDl15EvfqLFbQ==", + "cpu": [ + "x64" + ], + "dev": true, "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=12.22" + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.1.0" } }, - "node_modules/@humanwhocodes/retry": { - "version": "0.4.3", - "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", - "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.2.tgz", + "integrity": "sha512-NEE2vQ6wcxYav1/A22OOxoSOGiKnNmDzCYFOZ949xFmrWZOVII1Bp3NqVVpvj+3UeHMFyN5eP/V5hzViQ5CZNA==", + "cpu": [ + "arm64" + ], + "dev": true, "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=18.18" + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.1.0" } }, - "node_modules/@img/sharp-darwin-arm64": { + "node_modules/@img/sharp-linuxmusl-x64": { "version": "0.34.2", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.2.tgz", - "integrity": "sha512-OfXHZPppddivUJnqyKoi5YVeHRkkNE2zUFT2gbpKxp/JZCFYEYubnMg+gOp6lWfasPrTS+KPosKqdI+ELYVDtg==", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.2.tgz", + "integrity": "sha512-DOYMrDm5E6/8bm/yQLCWyuDJwUnlevR8xtF8bs+gjZ7cyUNYXiSf/E8Kp0Ss5xasIaXSHzb888V1BE4i1hFhAA==", "cpu": [ - "arm64" + "x64" ], "dev": true, "license": "Apache-2.0", "optional": true, "os": [ - "darwin" + "linux" ], "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" @@ -4252,22 +5354,85 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.1.0" + "@img/sharp-libvips-linuxmusl-x64": "1.1.0" } }, - "node_modules/@img/sharp-libvips-darwin-arm64": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.1.0.tgz", - "integrity": "sha512-HZ/JUmPwrJSoM4DIQPv/BfNh9yrOA8tlBbqbLz4JZ5uew2+o22Ik+tHQJcih7QJuSa0zo5coHTfD5J8inqj9DA==", + "node_modules/@img/sharp-wasm32": { + "version": "0.34.2", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.2.tgz", + "integrity": "sha512-/VI4mdlJ9zkaq53MbIG6rZY+QRN3MLbR6usYlgITEzi4Rpx5S6LFKsycOQjkOGmqTNmkIdLjEvooFKwww6OpdQ==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.4.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.2", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.2.tgz", + "integrity": "sha512-cfP/r9FdS63VA5k0xiqaNaEoGxBg9k7uE+RQGzuK9fHt7jib4zAVVseR9LsE4gJcNWgT6APKMNnCcnyOtmSEUQ==", "cpu": [ "arm64" ], "dev": true, - "license": "LGPL-3.0-or-later", + "license": "Apache-2.0 AND LGPL-3.0-or-later", "optional": true, "os": [ - "darwin" + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.34.2", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.2.tgz", + "integrity": "sha512-QLjGGvAbj0X/FXl8n1WbtQ6iVBpWU7JO94u/P2M4a8CFYsvQi4GW2mRy/JqkRx0qpBzaOdKJKw8uc930EX2AHw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.2", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.2.tgz", + "integrity": "sha512-aUdT6zEYtDKCaxkofmmJDJYGCf0+pJg3eU9/oBuqvEeoB9dKI6ZLc/1iLJCTuJQDO4ptntAlkUmHgGjyuobZbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, "funding": { "url": "https://opencollective.com/libvips" } @@ -4871,10 +6036,23 @@ }, "node_modules/@libsql/darwin-arm64": { "version": "0.4.7", - "resolved": "https://registry.npmjs.org/@libsql/darwin-arm64/-/darwin-arm64-0.4.7.tgz", - "integrity": "sha512-yOL742IfWUlUevnI5PdnIT4fryY3LYTdLm56bnY0wXBw7dhFcnjuA7jrH3oSVz2mjZTHujxoITgAE7V6Z+eAbg==", + "resolved": "https://registry.npmjs.org/@libsql/darwin-arm64/-/darwin-arm64-0.4.7.tgz", + "integrity": "sha512-yOL742IfWUlUevnI5PdnIT4fryY3LYTdLm56bnY0wXBw7dhFcnjuA7jrH3oSVz2mjZTHujxoITgAE7V6Z+eAbg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@libsql/darwin-x64": { + "version": "0.4.7", + "resolved": "https://registry.npmjs.org/@libsql/darwin-x64/-/darwin-x64-0.4.7.tgz", + "integrity": "sha512-ezc7V75+eoyyH07BO9tIyJdqXXcRfZMbKcLCeF8+qWK5nP8wWuMcfOVywecsXGRbT99zc5eNra4NEx6z5PkSsA==", "cpu": [ - "arm64" + "x64" ], "license": "MIT", "optional": true, @@ -4916,6 +6094,71 @@ "ws": "^8.13.0" } }, + "node_modules/@libsql/linux-arm64-gnu": { + "version": "0.4.7", + "resolved": "https://registry.npmjs.org/@libsql/linux-arm64-gnu/-/linux-arm64-gnu-0.4.7.tgz", + "integrity": "sha512-WlX2VYB5diM4kFfNaYcyhw5y+UJAI3xcMkEUJZPtRDEIu85SsSFrQ+gvoKfcVh76B//ztSeEX2wl9yrjF7BBCA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@libsql/linux-arm64-musl": { + "version": "0.4.7", + "resolved": "https://registry.npmjs.org/@libsql/linux-arm64-musl/-/linux-arm64-musl-0.4.7.tgz", + "integrity": "sha512-6kK9xAArVRlTCpWeqnNMCoXW1pe7WITI378n4NpvU5EJ0Ok3aNTIC2nRPRjhro90QcnmLL1jPcrVwO4WD1U0xw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@libsql/linux-x64-gnu": { + "version": "0.4.7", + "resolved": "https://registry.npmjs.org/@libsql/linux-x64-gnu/-/linux-x64-gnu-0.4.7.tgz", + "integrity": "sha512-CMnNRCmlWQqqzlTw6NeaZXzLWI8bydaXDke63JTUCvu8R+fj/ENsLrVBtPDlxQ0wGsYdXGlrUCH8Qi9gJep0yQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@libsql/linux-x64-musl": { + "version": "0.4.7", + "resolved": "https://registry.npmjs.org/@libsql/linux-x64-musl/-/linux-x64-musl-0.4.7.tgz", + "integrity": "sha512-nI6tpS1t6WzGAt1Kx1n1HsvtBbZ+jHn0m7ogNNT6pQHZQj7AFFTIMeDQw/i/Nt5H38np1GVRNsFe99eSIMs9XA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@libsql/win32-x64-msvc": { + "version": "0.4.7", + "resolved": "https://registry.npmjs.org/@libsql/win32-x64-msvc/-/win32-x64-msvc-0.4.7.tgz", + "integrity": "sha512-7pJzOWzPm6oJUxml+PCDRzYQ4A1hTMHAciTAHfFK4fkbDZX33nWPVG7Y3vqdKtslcwAzwmrNDc6sXy2nwWnbiw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@motionone/animation": { "version": "10.18.0", "resolved": "https://registry.npmjs.org/@motionone/animation/-/animation-10.18.0.tgz", @@ -6529,6 +7772,12 @@ "react-native": ">=0.59" } }, + "node_modules/@react-native-community/slider": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@react-native-community/slider/-/slider-5.1.1.tgz", + "integrity": "sha512-W98If/LnTaziU3/0h5+G1LvJaRhMc6iLQBte6UWa4WBIHDMaDPglNBIFKcCXc9Dxp83W+f+5Wv22Olq9M2HJYA==", + "license": "MIT" + }, "node_modules/@react-native-documents/picker": { "version": "10.1.5", "resolved": "https://registry.npmjs.org/@react-native-documents/picker/-/picker-10.1.5.tgz", @@ -8193,7 +9442,6 @@ "version": "0.10.1", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -8710,6 +9958,32 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/@unrs/resolver-binding-android-arm-eabi": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.11.1.tgz", + "integrity": "sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-android-arm64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.11.1.tgz", + "integrity": "sha512-lCxkVtb4wp1v+EoN+HjIG9cIIzPkX5OtM03pQYkG+U5O/wL53LC4QbIeazgiKqluGeVEeBlZahHalCaBvU1a2g==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, "node_modules/@unrs/resolver-binding-darwin-arm64": { "version": "1.11.1", "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.11.1.tgz", @@ -8723,6 +9997,229 @@ "darwin" ] }, + "node_modules/@unrs/resolver-binding-darwin-x64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.11.1.tgz", + "integrity": "sha512-cFzP7rWKd3lZaCsDze07QX1SC24lO8mPty9vdP+YVa3MGdVgPmFc59317b2ioXtgCMKGiCLxJ4HQs62oz6GfRQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-freebsd-x64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.11.1.tgz", + "integrity": "sha512-fqtGgak3zX4DCB6PFpsH5+Kmt/8CIi4Bry4rb1ho6Av2QHTREM+47y282Uqiu3ZRF5IQioJQ5qWRV6jduA+iGw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-gnueabihf": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.11.1.tgz", + "integrity": "sha512-u92mvlcYtp9MRKmP+ZvMmtPN34+/3lMHlyMj7wXJDeXxuM0Vgzz0+PPJNsro1m3IZPYChIkn944wW8TYgGKFHw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-musleabihf": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.11.1.tgz", + "integrity": "sha512-cINaoY2z7LVCrfHkIcmvj7osTOtm6VVT16b5oQdS4beibX2SYBwgYLmqhBjA1t51CarSaBuX5YNsWLjsqfW5Cw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.11.1.tgz", + "integrity": "sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.11.1.tgz", + "integrity": "sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-ppc64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.11.1.tgz", + "integrity": "sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.11.1.tgz", + "integrity": "sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.11.1.tgz", + "integrity": "sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-s390x-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.11.1.tgz", + "integrity": "sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.11.1.tgz", + "integrity": "sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.11.1.tgz", + "integrity": "sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-wasm32-wasi": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.11.1.tgz", + "integrity": "sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==", + "cpu": [ + "wasm32" + ], + "license": "MIT", + "optional": true, + "dependencies": { + "@napi-rs/wasm-runtime": "^0.2.11" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@unrs/resolver-binding-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", + "integrity": "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.4.3", + "@emnapi/runtime": "^1.4.3", + "@tybys/wasm-util": "^0.10.0" + } + }, + "node_modules/@unrs/resolver-binding-win32-arm64-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.11.1.tgz", + "integrity": "sha512-nRcz5Il4ln0kMhfL8S3hLkxI85BXs3o8EYoattsJNdsX4YUU89iOkVn7g0VHSRxFuVMdM4Q1jEpIId1Ihim/Uw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-ia32-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.11.1.tgz", + "integrity": "sha512-DCEI6t5i1NmAZp6pFonpD5m7i6aFrpofcp4LA2i8IIq60Jyo28hamKBxNrZcyOwVOZkgsRp9O2sXWBWP8MnvIQ==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-x64-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.11.1.tgz", + "integrity": "sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@urql/core": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@urql/core/-/core-5.2.0.tgz", @@ -11163,6 +12660,24 @@ "devOptional": true, "license": "MIT" }, + "node_modules/cross-env": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-10.1.0.tgz", + "integrity": "sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@epic-web/invariant": "^1.0.0", + "cross-spawn": "^7.0.6" + }, + "bin": { + "cross-env": "dist/bin/cross-env.js", + "cross-env-shell": "dist/bin/cross-env-shell.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/cross-fetch": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.2.0.tgz", diff --git a/package.json b/package.json index 906c82e6b..0c33a4546 100644 --- a/package.json +++ b/package.json @@ -18,9 +18,11 @@ "reset-project": "node ./scripts/reset-project.js", "rebuild-android": "git clean -xdf node_modules package-lock.json android ios && npm i && npx expo prebuild --platform android && cd android && gradlew clean && cd ..", "android": "expo run:android --device", - "android:prod": "expo run:android --device --variant release", + "android:preview": "npx cross-env EXPO_PUBLIC_APP_VARIANT=preview expo run:android --device --variant release", + "android:release": "npx cross-env EXPO_PUBLIC_APP_VARIANT=production expo run:android --device --variant release", "ios": "expo run:ios --device", - "ios:prod": "expo run:ios --device --variant release", + "ios:preview": "npx cross-env EXPO_PUBLIC_APP_VARIANT=preview expo run:ios --device --variant release", + "ios:release": "npx cross-env EXPO_PUBLIC_APP_VARIANT=production expo run:ios --device --variant release", "web": "npm run powersync:copy-assets && expo start --web", "db:view": "npx drizzle-lab@latest visualizer --project-id LangQuest", "test": "jest --watchAll", @@ -69,6 +71,7 @@ "@quidone/react-native-wheel-picker": "^1.6.1", "@react-native-async-storage/async-storage": "2.1.2", "@react-native-community/netinfo": "11.4.1", + "@react-native-community/slider": "^5.1.1", "@react-native-documents/picker": "^10.1.5", "@react-navigation/bottom-tabs": "^7.3.10", "@react-navigation/native": "^7.1.6", @@ -169,6 +172,7 @@ "@types/react-test-renderer": "^19.0.0", "babel-plugin-inline-import": "^3.0.0", "babel-preset-expo": "^54.0.6", + "cross-env": "^10.1.0", "default-gateway": "^7.2.2", "dotenv": "^16.4.7", "drizzle-kit": "^0.31.4", diff --git a/services/localizations.ts b/services/localizations.ts index 9d65c5d82..7e807a428 100644 --- a/services/localizations.ts +++ b/services/localizations.ts @@ -4754,11 +4754,58 @@ export const localizations = { indonesian: 'Durasi Jeda' }, vadSilenceDescription: { - english: 'How long to wait before stopping', - spanish: 'Tiempo de espera antes de detener', - brazilian_portuguese: 'Quanto tempo esperar antes de parar', - tok_pisin: 'Hamas taim bipo em i stop', - indonesian: 'Berapa lama menunggu sebelum berhenti' + english: 'How much silence is needed to determine segment boundaries.', + spanish: + 'Cuánto silencio se necesita para determinar los límites del segmento.', + brazilian_portuguese: + 'Quanto silêncio é necessário para determinar os limites do segmento.', + tok_pisin: 'Hamas taim i no gat nois bilong katim toktok.', + indonesian: + 'Berapa lama keheningan yang diperlukan untuk menentukan batas segmen.' + }, + vadMinSegmentLength: { + english: 'Minimum Segment Length', + spanish: 'Longitud Mínima de Segmento', + brazilian_portuguese: 'Comprimento Mínimo do Segmento', + tok_pisin: 'Liklik Taim Inap Bilong Toktok', + indonesian: 'Panjang Segmen Minimum' + }, + vadMinSegmentLengthDescription: { + english: 'Discard segments below this duration (filter brief noises)', + spanish: + 'Descartar segmentos por debajo de esta duración (filtrar ruidos breves)', + brazilian_portuguese: + 'Descartar segmentos abaixo desta duração (filtrar ruídos breves)', + tok_pisin: 'Rausim sotpela rekoding (filta liklik pairap)', + indonesian: 'Buang segmen di bawah durasi ini (filter suara singkat)' + }, + vadNoFilter: { + english: 'No filter', + spanish: 'Sin filtro', + brazilian_portuguese: 'Sem filtro', + tok_pisin: 'No filta', + indonesian: 'Tanpa filter' + }, + vadLightFilter: { + english: 'Light filter', + spanish: 'Filtro ligero', + brazilian_portuguese: 'Filtro leve', + tok_pisin: 'Liklik filta', + indonesian: 'Filter ringan' + }, + vadMediumFilter: { + english: 'Medium filter', + spanish: 'Filtro medio', + brazilian_portuguese: 'Filtro médio', + tok_pisin: 'Namel filta', + indonesian: 'Filter sedang' + }, + vadStrongFilter: { + english: 'Strong filter', + spanish: 'Filtro fuerte', + brazilian_portuguese: 'Filtro forte', + tok_pisin: 'Strongpela filta', + indonesian: 'Filter kuat' }, vadSensitive: { english: 'Sensitive', @@ -4866,28 +4913,52 @@ export const localizations = { indonesian: 'Cara Kerja' }, vadHelpAutomatic: { - english: 'Starts when you speak. Stops when you pause.', - spanish: 'Inicia cuando hablas. Se detiene cuando haces una pausa.', + english: + 'When sound is detected a segment will automatically start recording. After some silence the segment will be saved. You may record multiple segments like this in sequence while recording is activated.', + spanish: + 'Cuando se detecta sonido, un segmento comenzará a grabarse automáticamente. Después de un silencio, el segmento se guardará. Puedes grabar múltiples segmentos así en secuencia mientras la grabación está activada.', brazilian_portuguese: - 'Inicia quando você fala. Para quando você faz uma pausa.', - tok_pisin: 'Em i stat taim yu toktok. Em i stop taim yu pas.', - indonesian: 'Dimulai saat Anda berbicara. Berhenti saat Anda berhenti.' + 'Quando som é detectado, um segmento começará a gravar automaticamente. Após um silêncio, o segmento será salvo. Você pode gravar múltiplos segmentos assim em sequência enquanto a gravação está ativada.', + tok_pisin: + 'Taim masin i harim nois, em bai stat long rekodim. Bihain long taim i no gat nois, em bai sevim. Yu ken rekodim planti taim olsem wanwan taim rekod i op.', + indonesian: + 'Saat suara terdeteksi, segmen akan mulai merekam secara otomatis. Setelah keheningan, segmen akan disimpan. Anda dapat merekam beberapa segmen seperti ini secara berurutan saat perekaman diaktifkan.' }, vadHelpSensitivity: { - english: 'Lower sensitivity picks up quiet speech.', - spanish: 'Menor sensibilidad capta el habla tranquila.', - brazilian_portuguese: 'Menor sensibilidade capta fala baixa.', - tok_pisin: 'Liklik strong i harim smol toktok.', - indonesian: 'Sensitivitas rendah menangkap suara pelan.' + english: + 'Sensitivity sets the threshold to determine when a clip starts and ends. Lower sensitivity picks up quieter speech, but also other potential noises.', + spanish: + 'La sensibilidad controla el umbral que determina cuándo un clip comienza y termina. Con mayor sensibilidad se detecta habla más silenciosa, pero también más ruido de fondo.', + brazilian_portuguese: + 'A sensibilidade define o limite para determinar quando um clipe começa e termina. Sensibilidade mais baixa capta fala mais baixa, mas também outros ruídos potenciais.', + tok_pisin: + 'Sensitiv i makim hamas nois i nidim bilong stat na pinis. Liklik sensitiv i harim smol toktok, tasol em i ken harim tu ol narapela nois.', + indonesian: + 'Sensitivitas mengatur ambang batas untuk menentukan kapan klip dimulai dan berakhir. Sensitivitas rendah menangkap suara pelan, tetapi juga suara lain yang mungkin.' }, vadHelpPause: { - english: 'Shorter pause splits faster. Longer captures everything.', - spanish: 'Pausa más corta divide más rápido. Más larga captura todo.', + english: + 'A shorter Pause Length will break your recording into more segments at smaller pauses.', + spanish: + 'Una longitud de pausa más corta dividirá tu grabación en más segmentos en pausas más pequeñas.', brazilian_portuguese: - 'Pausa mais curta divide mais rápido. Mais longa captura tudo.', - tok_pisin: 'Liklik taim i katim kwik. Longpela i kisim olgeta.', + 'Um comprimento de pausa mais curto dividirá sua gravação em mais segmentos em pausas menores.', + tok_pisin: + 'Sotpela taim bilong pas bai katim rekod bilong yu long planti hap long ol liklik taim yu pas.', indonesian: - 'Jeda pendek memisahkan lebih cepat. Lebih lama menangkap semua.' + 'Durasi jeda yang lebih pendek akan memecah rekaman Anda menjadi lebih banyak segmen pada jeda yang lebih kecil.' + }, + vadHelpMinSegment: { + english: + 'Minimum Segment Length prevents saving of very short segments below the set duration, such as coughs or door slams.', + spanish: + 'La Longitud Mínima de Segmento evita guardar segmentos muy cortos por debajo de la duración establecida, como toses o portazos.', + brazilian_portuguese: + 'O Comprimento Mínimo do Segmento evita salvar segmentos muito curtos abaixo da duração definida, como tosses ou batidas de porta.', + tok_pisin: + 'Liklik Taim Inap i banisim ol sotpela rekod aninit long taim yu makim, olsem kus o doa i paitim.', + indonesian: + 'Panjang Segmen Minimum mencegah penyimpanan segmen yang sangat pendek di bawah durasi yang ditetapkan, seperti batuk atau bunyi pintu.' }, vadAutoCalibrate: { english: 'Auto-Calibrate', @@ -4913,6 +4984,18 @@ export const localizations = { indonesian: 'Kalibrasi gagal. Silakan coba lagi di lingkungan yang lebih tenang.' }, + vadCalibrateHint: { + english: + 'During auto-calibration remain silent or only allow sounds that you want to be below the sensitivity threshold.', + spanish: + 'Durante la auto-calibración permanece en silencio o solo permite sonidos que deseas que estén por debajo del umbral de sensibilidad.', + brazilian_portuguese: + 'Durante a auto-calibração permaneça em silêncio ou apenas permita sons que você deseja que fiquem abaixo do limite de sensibilidade.', + tok_pisin: + 'Taim masin i wokim kalibresen, yu mas stap isi o larim ol nois tasol we yu laik i stap aninit long mak.', + indonesian: + 'Selama kalibrasi otomatis tetaplah diam atau hanya izinkan suara yang ingin Anda agar berada di bawah ambang sensitivitas.' + }, appUpgradeRequired: { english: 'App Upgrade Required', spanish: 'Actualización de App Requerida', diff --git a/store/localStore.ts b/store/localStore.ts index d5450af8d..b396f09d4 100644 --- a/store/localStore.ts +++ b/store/localStore.ts @@ -44,12 +44,28 @@ export type Theme = 'light' | 'dark' | 'system'; // VAD (Voice Activity Detection) constants - single source of truth export const VAD_THRESHOLD_MIN = 0.001; export const VAD_THRESHOLD_MAX = 1.0; -export const VAD_THRESHOLD_DEFAULT = 0.05; +export const VAD_THRESHOLD_DEFAULT = 0.1; // VAD silence duration constants (in milliseconds) export const VAD_SILENCE_DURATION_MIN = 100; // 0.1 seconds export const VAD_SILENCE_DURATION_MAX = 3000; // 3 seconds -export const VAD_SILENCE_DURATION_DEFAULT = 300; // 0.3 seconds +export const VAD_SILENCE_DURATION_DEFAULT = 1000; // 1.0 seconds + +// New VAD algorithm settings +export const VAD_ONSET_MULTIPLIER_MIN = 0.05; +export const VAD_ONSET_MULTIPLIER_MAX = 0.5; +export const VAD_ONSET_MULTIPLIER_DEFAULT = 0.1; + +export const VAD_MAX_ONSET_DURATION_MIN = 50; +export const VAD_MAX_ONSET_DURATION_MAX = 500; +export const VAD_MAX_ONSET_DURATION_DEFAULT = 250; + +export const VAD_REWIND_HALF_PAUSE_DEFAULT = true; + +// Min segment length - discard clips with less than this much active audio (in ms) +export const VAD_MIN_SEGMENT_LENGTH_MIN = 0; +export const VAD_MIN_SEGMENT_LENGTH_MAX = 500; +export const VAD_MIN_SEGMENT_LENGTH_DEFAULT = 200; // Recently visited item types export interface RecentProject { @@ -119,6 +135,15 @@ export interface LocalState { setVadSilenceDuration: (duration: number) => void; vadDisplayMode: 'fullscreen' | 'footer'; setVadDisplayMode: (mode: 'fullscreen' | 'footer') => void; + // New VAD algorithm settings + vadOnsetMultiplier: number; + setVadOnsetMultiplier: (multiplier: number) => void; + vadMaxOnsetDuration: number; + setVadMaxOnsetDuration: (duration: number) => void; + vadRewindHalfPause: boolean; + setVadRewindHalfPause: (enabled: boolean) => void; + vadMinSegmentLength: number; + setVadMinSegmentLength: (duration: number) => void; // Authentication view state authView: @@ -243,6 +268,11 @@ export const useLocalStore = create()( vadThreshold: VAD_THRESHOLD_DEFAULT, vadSilenceDuration: VAD_SILENCE_DURATION_DEFAULT, vadDisplayMode: 'footer', // Default to footer mode + // New VAD algorithm settings (defaults) + vadOnsetMultiplier: VAD_ONSET_MULTIPLIER_DEFAULT, + vadMaxOnsetDuration: VAD_MAX_ONSET_DURATION_DEFAULT, + vadRewindHalfPause: VAD_REWIND_HALF_PAUSE_DEFAULT, + vadMinSegmentLength: VAD_MIN_SEGMENT_LENGTH_DEFAULT, // Authentication view state authView: null, @@ -345,7 +375,13 @@ export const useLocalStore = create()( set({ enableLanguoidLinkSuggestions: enabled }), // VAD settings setters - setVadThreshold: (threshold) => set({ vadThreshold: threshold }), + setVadThreshold: (threshold) => + set({ + vadThreshold: Math.max( + VAD_THRESHOLD_MIN, + Math.min(VAD_THRESHOLD_MAX, threshold) + ) + }), setVadSilenceDuration: (duration) => set({ vadSilenceDuration: Math.max( @@ -354,6 +390,29 @@ export const useLocalStore = create()( ) }), setVadDisplayMode: (mode) => set({ vadDisplayMode: mode }), + // New VAD algorithm setters + setVadOnsetMultiplier: (multiplier) => + set({ + vadOnsetMultiplier: Math.max( + VAD_ONSET_MULTIPLIER_MIN, + Math.min(VAD_ONSET_MULTIPLIER_MAX, multiplier) + ) + }), + setVadMaxOnsetDuration: (duration) => + set({ + vadMaxOnsetDuration: Math.max( + VAD_MAX_ONSET_DURATION_MIN, + Math.min(VAD_MAX_ONSET_DURATION_MAX, duration) + ) + }), + setVadRewindHalfPause: (enabled) => set({ vadRewindHalfPause: enabled }), + setVadMinSegmentLength: (duration) => + set({ + vadMinSegmentLength: Math.max( + VAD_MIN_SEGMENT_LENGTH_MIN, + Math.min(VAD_MIN_SEGMENT_LENGTH_MAX, duration) + ) + }), // Navigation context setters setCurrentContext: (projectId, questId, assetId) => @@ -463,7 +522,20 @@ export const useLocalStore = create()( // skipHydration: true, onRehydrateStorage: () => (state) => { console.log('rehydrating local store', state); - if (state) colorScheme.set(state.theme); + if (state) { + colorScheme.set(state.theme); + // Validate and clamp VAD threshold if invalid + if ( + typeof state.vadThreshold !== 'number' || + state.vadThreshold < VAD_THRESHOLD_MIN || + state.vadThreshold > VAD_THRESHOLD_MAX + ) { + console.warn( + `Invalid VAD threshold ${state.vadThreshold} detected, resetting to default ${VAD_THRESHOLD_DEFAULT}` + ); + state.vadThreshold = VAD_THRESHOLD_DEFAULT; + } + } }, partialize: (state) => Object.fromEntries( diff --git a/views/new/recording/components/FullScreenVADOverlay.tsx b/views/new/recording/components/FullScreenVADOverlay.tsx index 69530f843..324ca8172 100644 --- a/views/new/recording/components/FullScreenVADOverlay.tsx +++ b/views/new/recording/components/FullScreenVADOverlay.tsx @@ -20,6 +20,7 @@ interface FullScreenVADOverlayProps { energyShared: SharedValue; vadThreshold: number; isRecordingShared: SharedValue; + isDiscardedShared?: SharedValue; onCancel: () => void; } @@ -28,6 +29,7 @@ export function FullScreenVADOverlay({ energyShared, vadThreshold, isRecordingShared, + isDiscardedShared, onCancel }: FullScreenVADOverlayProps) { const { t } = useLocalization(); @@ -54,6 +56,7 @@ export function FullScreenVADOverlay({ energyShared={energyShared} vadThreshold={vadThreshold} isRecordingShared={isRecordingShared} + isDiscardedShared={isDiscardedShared} barCount={60} maxHeight={80} /> diff --git a/views/new/recording/components/RecordingControls.tsx b/views/new/recording/components/RecordingControls.tsx index 17fc92f71..3592e1aac 100644 --- a/views/new/recording/components/RecordingControls.tsx +++ b/views/new/recording/components/RecordingControls.tsx @@ -47,6 +47,7 @@ interface RecordingControlsProps { vadThreshold?: number; energyShared?: SharedValue; // For UI performance isRecordingShared?: SharedValue; // NEW: For instant waveform updates + isDiscardedShared?: SharedValue; // NEW: For retroactive blue effect displayMode?: 'fullscreen' | 'footer'; // Display mode preference } @@ -66,6 +67,7 @@ export const RecordingControls = React.memo( vadThreshold, energyShared, isRecordingShared, + isDiscardedShared, displayMode: _displayMode = 'footer' }: RecordingControlsProps) { const { t } = useLocalization(); @@ -375,7 +377,7 @@ export const RecordingControls = React.memo( ? walkieTalkieEnergyShared : (energyShared ?? fallbackEnergyShared) } - vadThreshold={vadThreshold ?? 0.085} + vadThreshold={vadThreshold ?? 0.05} isRecordingShared={ // Use VAD recording state during VAD lock, walkie-talkie state during walkie-talkie recording isVADLocked @@ -384,6 +386,7 @@ export const RecordingControls = React.memo( ? walkieTalkieIsRecordingShared : (isRecordingShared ?? fallbackIsRecordingShared) } + isDiscardedShared={isDiscardedShared} barCount={60} maxHeight={24} /> diff --git a/views/new/recording/components/RecordingViewSimplified.tsx b/views/new/recording/components/RecordingViewSimplified.tsx index f31977d9c..f9a216c32 100644 --- a/views/new/recording/components/RecordingViewSimplified.tsx +++ b/views/new/recording/components/RecordingViewSimplified.tsx @@ -136,6 +136,12 @@ const RecordingViewSimplified = ({ const setVadSilenceDuration = useLocalStore( (state) => state.setVadSilenceDuration ); + const vadMinSegmentLength = useLocalStore( + (state) => state.vadMinSegmentLength + ); + const setVadMinSegmentLength = useLocalStore( + (state) => state.setVadMinSegmentLength + ); const vadDisplayMode = useLocalStore((state) => state.vadDisplayMode); const setVadDisplayMode = useLocalStore((state) => state.setVadDisplayMode); const enablePlayAll = useLocalStore((state) => state.enablePlayAll); @@ -1306,17 +1312,22 @@ const RecordingViewSimplified = ({ debugLog('🎬 VAD: Segment starting | order_index:', targetOrder); currentRecordingOrderRef.current = targetOrder; - vadCounterRef.current = targetOrder + 1; // Increment for next segment + // Don't increment yet - wait until it's confirmed not to be a transient }, []); const handleVADSegmentComplete = React.useCallback( (uri: string) => { if (!uri || uri === '') { debugLog('🗑️ VAD: Segment discarded'); + // Do NOT increment counter - the next segment will reuse currentRecordingOrderRef.current return; } debugLog('📼 VAD: Segment complete'); + // Increment counter only for valid segments + if (vadCounterRef.current !== null) { + vadCounterRef.current += 1; + } void handleRecordingComplete(uri, 0, []); }, [handleRecordingComplete] @@ -1327,7 +1338,8 @@ const RecordingViewSimplified = ({ currentEnergy, isRecording: isVADRecording, energyShared, - isRecordingShared + isRecordingShared, + isDiscardedShared } = useVADRecording({ threshold: vadThreshold, silenceDuration: vadSilenceDuration, @@ -2192,6 +2204,7 @@ const RecordingViewSimplified = ({ energyShared={energyShared} vadThreshold={vadThreshold} isRecordingShared={isRecordingShared} + isDiscardedShared={isDiscardedShared} onCancel={() => { // Cancel VAD mode setIsVADLocked(false); @@ -2297,6 +2310,7 @@ const RecordingViewSimplified = ({ vadThreshold={vadThreshold} energyShared={energyShared} isRecordingShared={isRecordingShared} + isDiscardedShared={isDiscardedShared} displayMode={vadDisplayMode} /> )} @@ -2329,6 +2343,8 @@ const RecordingViewSimplified = ({ onThresholdChange={setVadThreshold} silenceDuration={vadSilenceDuration} onSilenceDurationChange={setVadSilenceDuration} + minSegmentLength={vadMinSegmentLength} + onMinSegmentLengthChange={setVadMinSegmentLength} isVADLocked={isVADLocked} displayMode={vadDisplayMode} onDisplayModeChange={setVadDisplayMode} diff --git a/views/new/recording/components/VADSettingsDrawer.tsx b/views/new/recording/components/VADSettingsDrawer.tsx index cbf8aa4b9..2a9c374b9 100644 --- a/views/new/recording/components/VADSettingsDrawer.tsx +++ b/views/new/recording/components/VADSettingsDrawer.tsx @@ -1,6 +1,7 @@ /** * VADSettingsDrawer - Settings drawer for voice activity detection * Shows live energy levels and allows threshold adjustment + * */ import { Button } from '@/components/ui/button'; @@ -9,11 +10,12 @@ import { DrawerClose, DrawerContent, DrawerDescription, - DrawerFooter, DrawerHeader, + DrawerScrollView, DrawerTitle } from '@/components/ui/drawer'; import { Icon } from '@/components/ui/icon'; +import { Slider } from '@/components/ui/slider'; import { Text } from '@/components/ui/text'; import { Tooltip, @@ -23,6 +25,10 @@ import { import { useLocalization } from '@/hooks/useLocalization'; import { useMicrophoneEnergy } from '@/hooks/useMicrophoneEnergy'; import { + VAD_MIN_SEGMENT_LENGTH_DEFAULT, + VAD_MIN_SEGMENT_LENGTH_MAX, + VAD_MIN_SEGMENT_LENGTH_MIN, + VAD_SILENCE_DURATION_DEFAULT, VAD_SILENCE_DURATION_MAX, VAD_SILENCE_DURATION_MIN, VAD_THRESHOLD_DEFAULT, @@ -30,12 +36,8 @@ import { VAD_THRESHOLD_MIN } from '@/store/localStore'; import { useThemeColor } from '@/utils/styleUtils'; -import { useGestureEventsHandlersDefault } from '@gorhom/bottom-sheet'; -import { LinearGradient } from 'expo-linear-gradient'; +import { PortalHost } from '@rn-primitives/portal'; import { - ArrowBigLeft, - ArrowBigRight, - ChevronUp, HelpCircle, Maximize2, Mic, @@ -43,19 +45,22 @@ import { Plus, RectangleHorizontal, RotateCcw, - Sparkles, - Volume1 + Sparkles } from 'lucide-react-native'; import React from 'react'; -import { ActivityIndicator, useWindowDimensions, View } from 'react-native'; +import { ActivityIndicator, View } from 'react-native'; import type { SharedValue } from 'react-native-reanimated'; import Animated, { + cancelAnimation, + useAnimatedProps, useAnimatedReaction, useAnimatedStyle, useDerivedValue, - useSharedValue + useSharedValue, + withTiming } from 'react-native-reanimated'; import Svg, { + Circle, Defs, Mask, Rect, @@ -64,26 +69,19 @@ import Svg, { } from 'react-native-svg'; import { scheduleOnRN } from 'react-native-worklets'; -// Dimmed gradient for sensitivity bar background -const ENERGY_GRADIENT_COLORS_DIMMED = [ - '#22c55e40', - '#84cc1640', - '#eab30840', - '#f9731640', - '#ef444440' -] as const; - // Segmented energy bar constants const ENERGY_BAR_PILL_WIDTH = 18; // Individual pill width in px const ENERGY_BAR_HEIGHT = 28; // Bar height in px const ENERGY_BAR_SPACING = 4; // Gap between pills in px const ENERGY_BAR_RADIUS = 4; // Pill corner radius in px -// Total horizontal padding: DrawerContent px-6 (24px × 2) + BottomSheetScrollView paddingHorizontal (16px × 2) -const ENERGY_BAR_HORIZONTAL_PADDING = 48; +// Energy bar width is now measured dynamically via onLayout + +// Animated circle for Reanimated +const AnimatedCircle = Animated.createAnimatedComponent(Circle); const SHOULD_SHOW_DISPLAY_MODE_SELECTION = false; -// Threshold constants +// Calibration constants const CALIBRATION_DURATION_MS = 3000; // 3 seconds const CALIBRATION_SAMPLE_INTERVAL_MS = 50; // Sample every 50ms const CALIBRATION_MULTIPLIER = 4.0; // 12 dB = ~4x multiplier @@ -93,14 +91,6 @@ const DB_MIN = -60; // Minimum dB (very quiet) const DB_MAX = 0; // Maximum dB (maximum level) // Pure helper functions (no component dependencies) -// CRITICAL: Native module sends energy as normalized amplitude (0-1 range) -// NOT raw RMS energy, so normalizeEnergy should only clamp, not divide -const normalizeEnergy = (energy: number): number => { - // Energy from native is already normalized amplitude (0-1) - // Only clamp to ensure it's in valid range - return Math.min(1.0, Math.max(0, energy)); -}; - const energyToDb = (energy: number): number => { // Energy is already normalized (0-1), just clamp if needed const normalized = energy > 1.0 ? 1.0 : Math.max(0, energy); @@ -119,45 +109,64 @@ const visualPositionToDb = (percent: number): number => { return DB_MIN + (clampedPercent / 100) * (DB_MAX - DB_MIN); }; -// Factory to create a gesture handler hook that tracks dragging state -// FIXED: Pass SharedValue directly instead of wrapping in a ref -// The ref wrapper was causing Reanimated warnings about modifying frozen objects -function createDraggingGestureHandler(draggingShared: SharedValue) { - // Return a hook function that will be called by BottomSheet - return function useDraggingGestureHandler() { - const defaultHandlers = useGestureEventsHandlersDefault(); - - return { - handleOnStart: ( - ...args: Parameters - ) => { - 'worklet'; - draggingShared.value = true; - defaultHandlers.handleOnStart(...args); - }, - handleOnChange: ( - ...args: Parameters - ) => { - 'worklet'; - defaultHandlers.handleOnChange(...args); - }, - handleOnEnd: ( - ...args: Parameters - ) => { - 'worklet'; - draggingShared.value = false; - defaultHandlers.handleOnEnd(...args); - }, - handleOnFinalize: ( - ...args: Parameters - ) => { - 'worklet'; - draggingShared.value = false; - defaultHandlers.handleOnFinalize(...args); - } - }; - }; -} +// Linear UI to exponential energy conversion (for intuitive slider) +// Maps linear UI value (0-1) to energy value (VAD_THRESHOLD_MIN to VAD_THRESHOLD_MAX) +// Uses logarithmic interpolation so the slider feels linear to users +const linearUIToEnergy = (linearValue: number): number => { + // Validate input + if (!isFinite(linearValue)) return VAD_THRESHOLD_DEFAULT; + + const clamped = Math.max(0, Math.min(1, linearValue)); + if (clamped === 0) return VAD_THRESHOLD_MIN; + if (clamped === 1) return VAD_THRESHOLD_MAX; + + // Logarithmic interpolation: e = min * (max/min)^u + // This makes the slider feel linear while mapping to exponential energy values + const ratio = VAD_THRESHOLD_MAX / VAD_THRESHOLD_MIN; + const energy = VAD_THRESHOLD_MIN * Math.pow(ratio, clamped); + + // Validate result and clamp to avoid precision issues + if (!isFinite(energy)) return VAD_THRESHOLD_DEFAULT; + + const result = Math.max( + VAD_THRESHOLD_MIN, + Math.min(VAD_THRESHOLD_MAX, Number(energy.toFixed(6))) + ); + + return isFinite(result) ? result : VAD_THRESHOLD_DEFAULT; +}; + +// Exponential energy to linear UI conversion (inverse of above) +const energyToLinearUI = (energy: number): number => { + // Validate input + if (!isFinite(energy) || energy <= 0) return 0; + if (energy >= VAD_THRESHOLD_MAX) return 1; + + const clamped = Math.max( + VAD_THRESHOLD_MIN, + Math.min(VAD_THRESHOLD_MAX, energy) + ); + if (clamped <= VAD_THRESHOLD_MIN) return 0; + if (clamped >= VAD_THRESHOLD_MAX) return 1; + + // Inverse logarithmic interpolation: u = log(e/min) / log(max/min) + const ratio = VAD_THRESHOLD_MAX / VAD_THRESHOLD_MIN; + const logRatio = Math.log(ratio); + + // Avoid division by zero or invalid log calculations + if (!isFinite(logRatio) || logRatio === 0) return 0; + + const numerator = Math.log(clamped / VAD_THRESHOLD_MIN); + if (!isFinite(numerator)) return 0; + + const linearValue = numerator / logRatio; + + // Ensure result is valid and clamped + const result = Math.max(0, Math.min(1, linearValue)); + return isFinite(result) ? result : 0; +}; + +// Using gorhom Drawer with custom slider that handles gestures properly interface VADSettingsDrawerProps { isOpen: boolean; @@ -166,6 +175,8 @@ interface VADSettingsDrawerProps { onThresholdChange: (threshold: number) => void; silenceDuration: number; onSilenceDurationChange: (duration: number) => void; + minSegmentLength: number; + onMinSegmentLengthChange: (duration: number) => void; isVADLocked?: boolean; // Don't stop detection if VAD is locked displayMode: 'fullscreen' | 'footer'; onDisplayModeChange: (mode: 'fullscreen' | 'footer') => void; @@ -180,6 +191,8 @@ function VADSettingsDrawerInternal({ onThresholdChange, silenceDuration, onSilenceDurationChange, + minSegmentLength, + onMinSegmentLengthChange, isVADLocked = false, displayMode, onDisplayModeChange, @@ -188,7 +201,6 @@ function VADSettingsDrawerInternal({ }: VADSettingsDrawerProps) { const { isActive, - energyResult: _energyResult, startEnergyDetection, stopEnergyDetection, resetEnergy, @@ -200,29 +212,132 @@ function VADSettingsDrawerInternal({ isVADLocked && externalEnergyShared ? externalEnergyShared : internalEnergyShared; + + // Ref to store latest energy value for calibration (updated via worklet bridge) + const latestEnergyRef = React.useRef(0); const { t } = useLocalization(); - const { width: screenWidth } = useWindowDimensions(); const accentColor = useThemeColor('accent'); - const mutedForegroundColor = useThemeColor('muted-foreground'); const primaryForegroundColor = useThemeColor('primary-foreground'); + const borderColor = useThemeColor('border'); - // Track dragging state on JS thread for SVG color changes - const [isDraggingJS, setIsDraggingJS] = React.useState(false); + // Measure actual container width for energy bar (adapts to any padding) + const [energyBarContainerWidth, setEnergyBarContainerWidth] = + React.useState(0); - // Calculate energy bar dimensions based on available width + // Calculate energy bar dimensions based on measured container width const { energyBarSegments, energyBarTotalWidth } = React.useMemo(() => { - const availableWidth = screenWidth - ENERGY_BAR_HORIZONTAL_PADDING; + if (energyBarContainerWidth === 0) { + return { energyBarSegments: 0, energyBarTotalWidth: 0 }; + } // N pills + (N-1) gaps = availableWidth // N = (availableWidth + spacing) / (pillWidth + spacing) const segments = Math.floor( - (availableWidth + ENERGY_BAR_SPACING) / + (energyBarContainerWidth + ENERGY_BAR_SPACING) / (ENERGY_BAR_PILL_WIDTH + ENERGY_BAR_SPACING) ); // Actual width: N pills + (N-1) gaps const totalWidth = segments * ENERGY_BAR_PILL_WIDTH + (segments - 1) * ENERGY_BAR_SPACING; return { energyBarSegments: segments, energyBarTotalWidth: totalWidth }; - }, [screenWidth]); + }, [energyBarContainerWidth]); + + // Local state for immediate UI updates (bypasses store persistence delay) + const [localThreshold, setLocalThreshold] = React.useState(threshold); + const [localSilenceDuration, setLocalSilenceDuration] = + React.useState(silenceDuration); + const [localMinSegmentLength, setLocalMinSegmentLength] = + React.useState(minSegmentLength); + const storeUpdateTimeoutRef = React.useRef | null>(null); + const silenceStoreUpdateTimeoutRef = React.useRef | null>(null); + const minSegmentStoreUpdateTimeoutRef = React.useRef | null>(null); + + // Sync local threshold with prop when it changes externally + React.useEffect(() => { + setLocalThreshold(threshold); + }, [threshold]); + + // Sync local silence duration with prop when it changes externally + React.useEffect(() => { + setLocalSilenceDuration(silenceDuration); + }, [silenceDuration]); + + // Sync local min segment length with prop when it changes externally + React.useEffect(() => { + setLocalMinSegmentLength(minSegmentLength); + }, [minSegmentLength]); + + // Immediate UI update function - updates local state instantly + const updateThresholdImmediate = React.useCallback( + (newThreshold: number) => { + // Update local state immediately - instant UI feedback, NO DELAYS + setLocalThreshold(newThreshold); + + // Clear any pending store update + if (storeUpdateTimeoutRef.current) { + clearTimeout(storeUpdateTimeoutRef.current); + } + + // Save to store in background (don't block UI thread) + storeUpdateTimeoutRef.current = setTimeout(() => { + onThresholdChange(newThreshold); + storeUpdateTimeoutRef.current = null; + }, 500); + }, + [onThresholdChange] + ); + + // Save settings when drawer closes (flush immediately) + React.useEffect(() => { + if (!isOpen) { + // Flush any pending threshold update + if (storeUpdateTimeoutRef.current) { + clearTimeout(storeUpdateTimeoutRef.current); + storeUpdateTimeoutRef.current = null; + } + if (localThreshold !== threshold) { + onThresholdChange(localThreshold); + } + + // Flush any pending silence duration update + if (silenceStoreUpdateTimeoutRef.current) { + clearTimeout(silenceStoreUpdateTimeoutRef.current); + silenceStoreUpdateTimeoutRef.current = null; + } + if (localSilenceDuration !== silenceDuration) { + onSilenceDurationChange(localSilenceDuration); + } + + // Flush any pending min segment length update + if (minSegmentStoreUpdateTimeoutRef.current) { + clearTimeout(minSegmentStoreUpdateTimeoutRef.current); + minSegmentStoreUpdateTimeoutRef.current = null; + } + if (localMinSegmentLength !== minSegmentLength) { + onMinSegmentLengthChange(localMinSegmentLength); + } + } + }, [isOpen]); // Only depend on isOpen to avoid unnecessary runs + + // Cleanup timeouts on unmount + React.useEffect(() => { + return () => { + if (storeUpdateTimeoutRef.current) { + clearTimeout(storeUpdateTimeoutRef.current); + } + if (silenceStoreUpdateTimeoutRef.current) { + clearTimeout(silenceStoreUpdateTimeoutRef.current); + } + if (minSegmentStoreUpdateTimeoutRef.current) { + clearTimeout(minSegmentStoreUpdateTimeoutRef.current); + } + }; + }, []); // Calibration state const [isCalibrating, setIsCalibrating] = React.useState(false); @@ -238,9 +353,6 @@ function VADSettingsDrawerInternal({ const calibrationTimeoutRef = React.useRef | null>(null); - // Ref to track latest energy value for calibration sampling - const latestEnergyRef = React.useRef(0); - // Sync threshold to SharedValue for UI thread access const thresholdShared = useSharedValue(threshold); const prevThresholdRef = React.useRef(threshold); @@ -300,11 +412,11 @@ function VADSettingsDrawerInternal({ thresholdPositionShared.value = dbToVisualPosition(thresholdDb); }, [threshold, thresholdPositionShared]); - // JS thread version for button handlers (only recalculates when threshold prop changes) + // JS thread version for button handlers (uses localThreshold for instant UI response) const thresholdPosition = React.useMemo(() => { - const thresholdDb = energyToDb(threshold); + const thresholdDb = energyToDb(localThreshold); return dbToVisualPosition(thresholdDb); - }, [threshold]); + }, [localThreshold]); // Cancel any in-progress calibration const cancelCalibration = React.useCallback(() => { @@ -321,6 +433,37 @@ function VADSettingsDrawerInternal({ setCalibrationProgress(0); }, []); + // Button handlers for threshold adjustment + const handleDecreaseThreshold = React.useCallback(() => { + const currentPos = thresholdPosition; + const stepPercent = 2; + const newPos = Math.max(0, currentPos - stepPercent); + const newDb = visualPositionToDb(newPos); + const clampedDb = Math.max(DB_MIN, Math.min(DB_MAX, newDb)); + const newEnergy = + clampedDb <= DB_MIN ? VAD_THRESHOLD_MIN : Math.pow(10, clampedDb / 20); + const newThreshold = Math.max( + VAD_THRESHOLD_MIN, + Math.min(VAD_THRESHOLD_MAX, newEnergy) + ); + updateThresholdImmediate(Number(newThreshold.toFixed(4))); + }, [thresholdPosition, updateThresholdImmediate]); + + const handleIncreaseThreshold = React.useCallback(() => { + const currentPos = thresholdPosition; + const stepPercent = 2; + const newPos = Math.min(100, currentPos + stepPercent); + const newDb = visualPositionToDb(newPos); + const clampedDb = Math.max(DB_MIN, Math.min(DB_MAX, newDb)); + const newEnergy = + clampedDb <= DB_MIN ? VAD_THRESHOLD_MIN : Math.pow(10, clampedDb / 20); + const newThreshold = Math.max( + VAD_THRESHOLD_MIN, + Math.min(VAD_THRESHOLD_MAX, newEnergy) + ); + updateThresholdImmediate(Number(newThreshold.toFixed(4))); + }, [thresholdPosition, updateThresholdImmediate]); + // Start monitoring when drawer opens, stop when it closes (unless VAD is locked) // Use refs to track previous state and avoid infinite loops from isActive dependency const prevIsOpenRef = React.useRef(isOpen); @@ -329,9 +472,11 @@ function VADSettingsDrawerInternal({ const isActiveRef = React.useRef(isActive); // Keep refs updated with latest values (safe - refs don't trigger re-renders) - startEnergyDetectionRef.current = startEnergyDetection; - stopEnergyDetectionRef.current = stopEnergyDetection; - isActiveRef.current = isActive; + React.useEffect(() => { + startEnergyDetectionRef.current = startEnergyDetection; + stopEnergyDetectionRef.current = stopEnergyDetection; + isActiveRef.current = isActive; + }, [startEnergyDetection, stopEnergyDetection, isActive]); React.useEffect(() => { const wasOpen = prevIsOpenRef.current; @@ -393,22 +538,19 @@ function VADSettingsDrawerInternal({ prevIsOpenRef.current = isOpen; }, [isOpen, isVADLocked, cancelCalibration, resetEnergy]); - // Logging completely disabled for performance - // To enable: uncomment the effects below and set ENABLE_VAD_LOGGING = true - // const ENABLE_VAD_LOGGING = false; - // Logging effects removed to prevent any potential re-render triggers - // Reset to default threshold - const handleResetToDefault = () => { - onThresholdChange(VAD_THRESHOLD_DEFAULT); - }; + const handleResetToDefault = React.useCallback(() => { + updateThresholdImmediate(VAD_THRESHOLD_DEFAULT); + }, [updateThresholdImmediate]); // Auto-calibrate function // Use refs for isCalibrating and isActive to avoid dependency loops const isCalibratingRefForCallback = React.useRef(isCalibrating); const isActiveRefForCallback = React.useRef(isActive); - isCalibratingRefForCallback.current = isCalibrating; - isActiveRefForCallback.current = isActive; + React.useEffect(() => { + isCalibratingRefForCallback.current = isCalibrating; + isActiveRefForCallback.current = isActive; + }, [isCalibrating, isActive]); const handleAutoCalibrate = React.useCallback(async () => { if (isCalibratingRefForCallback.current) return; @@ -497,8 +639,8 @@ function VADSettingsDrawerInternal({ Math.min(VAD_THRESHOLD_MAX, normalizedAverage * CALIBRATION_MULTIPLIER) ); - // Apply threshold automatically (use ref to avoid dependency issues) - onThresholdChangeRef.current?.(Number(newThreshold.toFixed(4))); + // Apply threshold automatically + updateThresholdImmediate(Number(newThreshold.toFixed(4))); setIsCalibrating(false); setCalibrationProgress(0); @@ -506,7 +648,7 @@ function VADSettingsDrawerInternal({ }, totalDuration); calibrationTimeoutRef.current = timeout; - }, [t]); // Removed isCalibrating, isActive, startEnergyDetection from deps - using refs instead + }, [t, updateThresholdImmediate]); // Removed isCalibrating, isActive, startEnergyDetection from deps - using refs instead // Auto-calibrate when drawer opens with autoCalibrateOnOpen flag const hasAutoCalibratedRef = React.useRef(false); @@ -561,45 +703,94 @@ function VADSettingsDrawerInternal({ }; }, [cancelCalibration]); + // Immediate update function for silence duration + const updateSilenceDurationImmediate = React.useCallback( + (newDuration: number) => { + // Update local state immediately - instant UI feedback + setLocalSilenceDuration(newDuration); + + // Clear any pending store update + if (silenceStoreUpdateTimeoutRef.current) { + clearTimeout(silenceStoreUpdateTimeoutRef.current); + } + + // Save to store in background (don't block UI) + silenceStoreUpdateTimeoutRef.current = setTimeout(() => { + onSilenceDurationChange(newDuration); + silenceStoreUpdateTimeoutRef.current = null; + }, 300); + }, + [onSilenceDurationChange] + ); + // Increment/decrement handlers for silence duration - const incrementSilence = () => { - const newValue = Math.min(VAD_SILENCE_DURATION_MAX, silenceDuration + 100); - onSilenceDurationChange(newValue); - }; + const incrementSilence = React.useCallback(() => { + const newValue = Math.min( + VAD_SILENCE_DURATION_MAX, + localSilenceDuration + 100 + ); + updateSilenceDurationImmediate(newValue); + }, [localSilenceDuration, updateSilenceDurationImmediate]); - const decrementSilence = () => { - const newValue = Math.max(VAD_SILENCE_DURATION_MIN, silenceDuration - 100); - onSilenceDurationChange(newValue); - }; + const decrementSilence = React.useCallback(() => { + const newValue = Math.max( + VAD_SILENCE_DURATION_MIN, + localSilenceDuration - 100 + ); + updateSilenceDurationImmediate(newValue); + }, [localSilenceDuration, updateSilenceDurationImmediate]); + + const resetSilenceDuration = React.useCallback(() => { + updateSilenceDurationImmediate(VAD_SILENCE_DURATION_DEFAULT); + }, [updateSilenceDurationImmediate]); + + // Immediate update function for min segment length + const updateMinSegmentLengthImmediate = React.useCallback( + (newLength: number) => { + // Update local state immediately - instant UI feedback + setLocalMinSegmentLength(newLength); + + // Clear any pending store update + if (minSegmentStoreUpdateTimeoutRef.current) { + clearTimeout(minSegmentStoreUpdateTimeoutRef.current); + } + + // Save to store in background (don't block UI) + minSegmentStoreUpdateTimeoutRef.current = setTimeout(() => { + onMinSegmentLengthChange(newLength); + minSegmentStoreUpdateTimeoutRef.current = null; + }, 300); + }, + [onMinSegmentLengthChange] + ); + + // Increment/decrement handlers for min segment length (50ms steps) + const incrementMinSegmentLength = React.useCallback(() => { + const newValue = Math.min( + VAD_MIN_SEGMENT_LENGTH_MAX, + localMinSegmentLength + 50 + ); + updateMinSegmentLengthImmediate(newValue); + }, [localMinSegmentLength, updateMinSegmentLengthImmediate]); + + const decrementMinSegmentLength = React.useCallback(() => { + const newValue = Math.max( + VAD_MIN_SEGMENT_LENGTH_MIN, + localMinSegmentLength - 50 + ); + updateMinSegmentLengthImmediate(newValue); + }, [localMinSegmentLength, updateMinSegmentLengthImmediate]); + + const resetMinSegmentLength = React.useCallback(() => { + updateMinSegmentLengthImmediate(VAD_MIN_SEGMENT_LENGTH_DEFAULT); + }, [updateMinSegmentLengthImmediate]); // Energy level as pixel width for SVG (with frame skipping to match native ~21fps) const frameCounter = useSharedValue(0); const cachedEnergyLevel = useSharedValue(0); // SharedValue for energy bar width (so worklets can access it) const energyBarWidthShared = useSharedValue(energyBarTotalWidth); - // Track if drawer is being dragged (to pause calculations) - const isDragging = useSharedValue(false); - - // Create custom gesture handler hook using factory - // FIXED: Pass SharedValue directly - refs were causing Reanimated warnings - const gestureHandlerHook = React.useMemo( - () => createDraggingGestureHandler(isDragging), - [isDragging] - ); - - // Sync isDragging to JS thread for SVG color changes - // CRITICAL: Must use function reference with scheduleOnRN, not inline arrow - const updateIsDraggingJS = React.useCallback((dragging: boolean) => { - setIsDraggingJS(dragging); - }, []); - - useAnimatedReaction( - () => isDragging.value, - (dragging) => { - 'worklet'; - scheduleOnRN(updateIsDraggingJS, dragging); - } - ); + // Custom slider handles gesture conflicts with BottomSheet // Keep shared value in sync with memoized value React.useEffect(() => { @@ -609,13 +800,6 @@ function VADSettingsDrawerInternal({ // Animated style for the energy fill overlay (clips the gradient SVG) const energyFillStyle = useAnimatedStyle(() => { 'worklet'; - // Skip calculations when drawer is being dragged - if (isDragging.value) { - return { - width: (cachedEnergyLevel.value / 100) * energyBarWidthShared.value - }; - } - const counter = frameCounter.value; frameCounter.value = (counter + 1) % 3; @@ -650,480 +834,658 @@ function VADSettingsDrawerInternal({ return { left: posPixels }; }); - // Animated styles for threshold marker on sensitivity bar (percentage) - const thresholdMarkerPercentStyle = useAnimatedStyle(() => { - 'worklet'; - const pos = Math.min(100, Math.max(0, thresholdPositionShared.value)); - return { left: `${pos}%` }; - }); - // Animated styles for status indicator (runs entirely on UI thread, instant - no animation) const recordingStatusStyle = useAnimatedStyle(() => { 'worklet'; - if (isDragging.value) return { opacity: 0 }; const isAbove = cachedEnergyLevel.value > thresholdPositionShared.value; return { opacity: isAbove ? 1 : 0 }; }); const waitingStatusStyle = useAnimatedStyle(() => { 'worklet'; - if (isDragging.value) return { opacity: 0 }; const isAbove = cachedEnergyLevel.value > thresholdPositionShared.value; return { opacity: isAbove ? 0 : 1 }; }); - const pausedStatusStyle = useAnimatedStyle(() => { - 'worklet'; - return { opacity: isDragging.value ? 1 : 0 }; + // Silence timer progress (1 = full/speaking, 0 = empty/will split) + const silenceTimerProgress = useSharedValue(0); + const wasAboveThreshold = useSharedValue(false); + const silenceDurationShared = useSharedValue(localSilenceDuration); + + // Keep silence duration shared value in sync + React.useEffect(() => { + silenceDurationShared.value = localSilenceDuration; + }, [localSilenceDuration, silenceDurationShared]); + + // Track silence timer on UI thread: fills instantly when speaking, drains when quiet + useAnimatedReaction( + () => ({ + energyLevel: cachedEnergyLevel.value, + threshold: thresholdPositionShared.value + }), + (current) => { + 'worklet'; + const isAboveThreshold = current.energyLevel > current.threshold; + + if (isAboveThreshold) { + // Speaking: instantly fill to 100% + cancelAnimation(silenceTimerProgress); + silenceTimerProgress.value = 1; + wasAboveThreshold.value = true; + } else if (wasAboveThreshold.value) { + // Just went quiet: start draining over silenceDuration + wasAboveThreshold.value = false; + silenceTimerProgress.value = withTiming(0, { + duration: silenceDurationShared.value + }); + } + } + ); + + // Circular progress indicator props + const CIRCLE_SIZE = 32; + const STROKE_WIDTH = 5; + const RADIUS = (CIRCLE_SIZE - STROKE_WIDTH) / 2; + const CIRCUMFERENCE = 2 * Math.PI * RADIUS; + + const animatedCircleProps = useAnimatedProps(() => { + const progress = silenceTimerProgress.value; + const strokeDashoffset = CIRCUMFERENCE * (1 - progress); + return { strokeDashoffset }; }); return ( - - - - - {t('vadTitle')} - {t('vadDescription')} - - - - - - - - {t('vadHelpTitle')} - {'\n'} - {'\n'} - {t('vadHelpAutomatic')} - {'\n'} - {'\n'} - {t('vadHelpSensitivity')} - {'\n'} - {'\n'} - {t('vadHelpPause')} - - - - - - - {/* Display Mode Selection */} - {/* eslint-disable-next-line @typescript-eslint/no-unnecessary-condition */} - {SHOULD_SHOW_DISPLAY_MODE_SELECTION && ( - - - - {t('vadDisplayMode')} - - - {t('vadDisplayDescription')} - + + + + + + + {t('vadTitle')} + {t('vadDescription')} - - - {/* Full Screen Option */} - + + {t('vadHelpTitle')} + {t('vadHelpAutomatic')} + {t('vadHelpSensitivity')} + {t('vadHelpPause')} + {t('vadHelpMinSegment')} + + + + + + + {/* ═══════════════════════════════════════════════════════════════ + SECTION 1: Display Mode Selection (currently hidden) + Choose between fullscreen overlay or footer-based VAD display + ═══════════════════════════════════════════════════════════════ */} + {/* eslint-disable-next-line @typescript-eslint/no-unnecessary-condition */} + {SHOULD_SHOW_DISPLAY_MODE_SELECTION && ( + + + + {t('vadDisplayMode')} + + + {t('vadDisplayDescription')} + + - {/* Footer Option */} - + + {/* Footer Option */} + + + + )} + {/* ═══════════════════════════════════════════════════════════════ + SECTION 2: Input Level Visualization + Live microphone energy meter showing current audio level + Red threshold marker shows where VAD will trigger + ═══════════════════════════════════════════════════════════════ */} + + + + + {t('vadCurrentLevel')} - - - - )} - {/* Input Level Visualization */} - - - - - {t('vadCurrentLevel')} - - + - - - + setEnergyBarContainerWidth(e.nativeEvent.layout.width) + } > - {Array.from({ length: energyBarSegments }).map((_, i) => ( - - ))} - - - - - - - - - - - - - - {Array.from({ length: energyBarSegments }).map( - (_, i) => ( - - ) - )} - - - + + {Array.from({ length: energyBarSegments }).map((_, i) => ( + + ))} + + + + + + + + + + + + + + {Array.from({ length: energyBarSegments }).map( + (_, i) => ( + + ) + )} + + + + + + + - - - - + + + + + + + 💤 {t('vadWaiting')} + + + + + 🎤 {t('vadRecordingNow')} + + + - - - - - 💤 {t('vadWaiting')} - - - - - 🎤 {t('vadRecordingNow')} - - - - - ⏸️ {t('vadPaused')} - - - - + {/* ═══════════════════════════════════════════════════════════════ + SECTION 3: Threshold Settings + Adjust VAD sensitivity - lower = more sensitive, higher = less + Includes auto-calibration button that samples background noise + ═══════════════════════════════════════════════════════════════ */} + + + + + {t('vadThreshold')} + + + Lower = more sensitive, higher = less sensitive + + + + - - - - - - {t('vadThreshold')} - - - - + + + {energyToLinearUI(localThreshold).toFixed(2)} + + + ( + {energyToLinearUI(localThreshold) <= 0.2 + ? 'very sensitive' + : energyToLinearUI(localThreshold) <= 0.4 + ? 'sensitive' + : energyToLinearUI(localThreshold) <= 0.6 + ? 'balanced' + : energyToLinearUI(localThreshold) <= 0.8 + ? 'less sensitive' + : 'not sensitive'} + ) + + - - - - - + + + + {/* Slider uses linear UI (0-1) for intuitive feel, converts to exponential energy under the hood */} + { + // Validate input before conversion + if ( + !isFinite(linearValue) || + linearValue < 0 || + linearValue > 1 + ) + return; + // Convert linear UI value to energy value + const newEnergy = linearUIToEnergy(linearValue); + if (isFinite(newEnergy)) { + updateThresholdImmediate(newEnergy); + } + }} + minimumTrackTintColor={useThemeColor('primary')} + maximumTrackTintColor={borderColor} + thumbTintColor={useThemeColor('primary')} + /> + + + + + + + + {calibrationError && ( + + {calibrationError} + + )} + + + {t('vadCalibrateHint')} + - - - - - - - - - + + + + {/* Value display */} + + + {(localSilenceDuration / 1000).toFixed(1)}s + + + ( + {localSilenceDuration < 1000 + ? t('vadQuickSegments') + : localSilenceDuration <= 1500 + ? t('vadBalanced') + : t('vadCompleteThoughts')} + ) - )} - - {calibrationError && ( - - {calibrationError} - - )} - - + {/* Circular silence timer indicator */} + + + {/* Background circle */} + + {/* Animated progress circle */} + + + + - - - {t('vadSilenceDuration')} - - - - - - - - {(silenceDuration / 1000).toFixed(1)}s - - - {silenceDuration < 1000 - ? t('vadQuickSegments') - : silenceDuration <= 1500 - ? t('vadBalanced') - : t('vadCompleteThoughts')} - + + + + + + + - + {/* ═══════════════════════════════════════════════════════════════ + SECTION 5: Min Segment Length (Transient Filter) + Discard segments shorter than this duration to filter out + brief noises like claps, coughs, and other transients. + NOTE: Slider dragging has known issues with bottom sheet + gesture handling - use +/- buttons or tap on slider track. + ═══════════════════════════════════════════════════════════════ */} + + + + + {t('vadMinSegmentLength')} + + + {t('vadMinSegmentLengthDescription')} + + + + + + + + {(localMinSegmentLength / 1000).toFixed(2)}s + + + ( + {localMinSegmentLength === 0 + ? t('vadNoFilter') + : localMinSegmentLength <= 150 + ? t('vadLightFilter') + : localMinSegmentLength <= 300 + ? t('vadMediumFilter') + : t('vadStrongFilter')} + ) + + + + + + + + + + + + - - {t('vadSilenceDescription')} - + {/* Footer pinned to bottom, outside scroll view */} + + + {t('done')} + - - - - {t('done')} - - + {/* PortalHost for tooltips - must be LAST to render on top */} + + ); @@ -1145,6 +1507,7 @@ export const VADSettingsDrawer = React.memo( const primitivePropsEqual = prevProps.threshold === nextProps.threshold && prevProps.silenceDuration === nextProps.silenceDuration && + prevProps.minSegmentLength === nextProps.minSegmentLength && prevProps.isVADLocked === nextProps.isVADLocked && prevProps.displayMode === nextProps.displayMode && prevProps.autoCalibrateOnOpen === nextProps.autoCalibrateOnOpen && diff --git a/views/new/recording/hooks/useVADRecording.ts b/views/new/recording/hooks/useVADRecording.ts index f374a6876..5873dfab8 100644 --- a/views/new/recording/hooks/useVADRecording.ts +++ b/views/new/recording/hooks/useVADRecording.ts @@ -11,7 +11,7 @@ import { useMicrophoneEnergy } from '@/hooks/useMicrophoneEnergy'; import MicrophoneEnergyModule from '@/modules/microphone-energy'; -import { VAD_SILENCE_DURATION_MIN } from '@/store/localStore'; +import { VAD_SILENCE_DURATION_MIN, useLocalStore } from '@/store/localStore'; import React from 'react'; import type { SharedValue } from 'react-native-reanimated'; import { useSharedValue } from 'react-native-reanimated'; @@ -30,6 +30,7 @@ interface UseVADRecordingReturn { isRecording: boolean; // Keep for React components energyShared: SharedValue; // For UI performance isRecordingShared: SharedValue; // NEW: For instant UI updates + isDiscardedShared: SharedValue; // NEW: Increments when a segment is discarded } export function useVADRecording({ @@ -40,19 +41,21 @@ export function useVADRecording({ onSegmentComplete, isManualRecording }: UseVADRecordingProps): UseVADRecordingReturn { - const { - isActive, - energyResult, - startEnergyDetection, - stopEnergyDetection, - energyShared - } = useMicrophoneEnergy(); + const micEnergy = useMicrophoneEnergy(); + // Extract with proper types to avoid TypeScript issues with .web.ts resolution + const isActive = micEnergy.isActive; + const startEnergyDetection = micEnergy.startEnergyDetection; + const stopEnergyDetection = micEnergy.stopEnergyDetection; + const energyShared = micEnergy.energyShared; + // Create a typed reference to the energy ref + const energyRef: { current: number } = micEnergy.energyRef; const [isRecording, setIsRecording] = React.useState(false); // NEW: SharedValue for INSTANT UI updates (bypasses React render cycle) // This updates synchronously when native VAD fires events - NO LAG! const isRecordingShared = useSharedValue(false); + const isDiscardedShared = useSharedValue(0); // Stable refs for callbacks const onSegmentStartRef = React.useRef(onSegmentStart); @@ -74,64 +77,51 @@ export function useVADRecording({ onSegmentCompleteRef.current = onSegmentComplete; }, [onSegmentStart, onSegmentComplete]); - const currentEnergy = energyResult?.energy ?? 0; + // Use ref directly - no re-renders on energy changes! + const currentEnergy = energyRef.current; - // Track energy range during recording - React.useEffect(() => { - if (isRecording && energyResult) { - const energy = energyResult.energy; - if (energyRangeRef.current) { - energyRangeRef.current.min = Math.min( - energyRangeRef.current.min, - energy - ); - energyRangeRef.current.max = Math.max( - energyRangeRef.current.max, - energy - ); - } else { - energyRangeRef.current = { min: energy, max: energy }; - } - } - }, [isRecording, energyResult]); + // NOTE: Energy range tracking removed - was causing re-renders on every energy update. + // The energyRangeRef is now updated via energyRef when segments start/end. // Track previous settings to detect actual changes (not just effect re-runs) const prevThresholdRef = React.useRef(threshold); const prevSilenceDurationRef = React.useRef(silenceDuration); + // Get new VAD algorithm settings from store + const vadOnsetMultiplier = useLocalStore((s) => s.vadOnsetMultiplier); + const vadMaxOnsetDuration = useLocalStore((s) => s.vadMaxOnsetDuration); + const vadRewindHalfPause = useLocalStore((s) => s.vadRewindHalfPause); + const vadMinSegmentLength = useLocalStore((s) => s.vadMinSegmentLength); + // Configure native VAD and manage activation/deactivation - // CRITICAL: configureVAD() must complete BEFORE enableVAD() to ensure correct threshold - // This fixes the race condition where VAD starts with default (more sensitive) threshold + // NEW ALGORITHM: Uses threshold directly without Schmitt trigger scaling React.useEffect(() => { // Configure VAD whenever settings change (even if already active) - // This ensures settings are always up-to-date before VAD is enabled const configureVAD = async () => { - // CRITICAL: Pass threshold directly without scaling - // The native module now uses 0-1 peak amplitude, same as our normalized threshold - const rawThreshold = threshold; - - // CRITICAL: The native module multiplies threshold by onsetMultiplier (0.25) for onset detection - // So if user wants final threshold of X, we need to send X / 0.25 = 4X - // This ensures: (4X) * 0.25 = X (the desired threshold) - // The threshold value represents the FINAL effective threshold, not the base - const ONSET_MULTIPLIER = 0.25; - const baseThreshold = rawThreshold / ONSET_MULTIPLIER; - + // NEW ALGORITHM: Pass threshold directly - no more multiplier scaling + // The new native module uses raw peak amplitude with pre-onset buffer console.log( - '🔧 Configuring VAD | normalized:', - threshold, - '→ raw:', - rawThreshold.toFixed(4), - '→ base (accounting for onsetMultiplier):', - baseThreshold.toFixed(4) + '🔧 Configuring VAD | threshold:', + threshold.toFixed(4), + '| onsetMultiplier:', + vadOnsetMultiplier, + '| maxOnsetDuration:', + vadMaxOnsetDuration, + '| rewindHalfPause:', + vadRewindHalfPause, + '| minSegmentLength:', + vadMinSegmentLength ); await MicrophoneEnergyModule.configureVAD({ - threshold: baseThreshold, + threshold, // Direct threshold - what you see is what you get silenceDuration, - onsetMultiplier: 0.25, - confirmMultiplier: 0.5, - minSegmentDuration: VAD_SILENCE_DURATION_MIN // Use minimum silence duration instead of hardcoded 500ms + minSegmentDuration: VAD_SILENCE_DURATION_MIN, + // New algorithm settings + onsetMultiplier: vadOnsetMultiplier, + maxOnsetDuration: vadMaxOnsetDuration, + rewindHalfPause: vadRewindHalfPause, + minActiveAudioDuration: vadMinSegmentLength }); }; @@ -169,20 +159,12 @@ export function useVADRecording({ console.log('✅ Energy detection started, enabling VAD...'); await MicrophoneEnergyModule.enableVAD(); - // Log threshold values for debugging - const ONSET_MULTIPLIER = 0.25; - const rawThreshold = threshold; - const baseThreshold = rawThreshold / ONSET_MULTIPLIER; - const effectiveOnsetThreshold = baseThreshold * ONSET_MULTIPLIER; + // Log threshold values for debugging (new algorithm uses threshold directly) console.log( - '✅ VAD enabled | normalized:', - threshold, - '| raw (effective):', - rawThreshold.toFixed(4), - '| base (sent to native):', - baseThreshold.toFixed(4), - '| effective onset:', - effectiveOnsetThreshold.toFixed(4) + '✅ VAD enabled | threshold:', + threshold.toFixed(4), + '| onset:', + (threshold * vadOnsetMultiplier).toFixed(4) ); // Update refs after successful activation @@ -223,6 +205,10 @@ export function useVADRecording({ isManualRecording, threshold, silenceDuration, + vadOnsetMultiplier, + vadMaxOnsetDuration, + vadRewindHalfPause, + vadMinSegmentLength, startEnergyDetection, stopEnergyDetection ]); @@ -243,7 +229,7 @@ export function useVADRecording({ segmentStartTimeRef.current = Date.now(); // Initialize energy range tracking for this segment - const initialEnergy = energyResult?.energy ?? 0; + const initialEnergy = energyRef.current; energyRangeRef.current = { min: initialEnergy, max: initialEnergy }; // Set a timeout to clean up if segment never completes (e.g., discarded for being too short) @@ -276,10 +262,15 @@ export function useVADRecording({ (payload: { uri: string; duration: number }) => { console.log( '📼 Native VAD: Segment complete:', - payload.uri, + payload.uri || 'DISCARDED', `(${payload.duration}ms)` ); + // If discarded, trigger retroactive UI update + if (!payload.uri || payload.uri === '') { + isDiscardedShared.value += 1; + } + // Log energy range for VAD recording if (energyRangeRef.current) { const range = energyRangeRef.current; @@ -317,6 +308,7 @@ export function useVADRecording({ currentEnergy, isRecording, energyShared, - isRecordingShared // NEW: For instant waveform updates + isRecordingShared, // NEW: For instant waveform updates + isDiscardedShared }; } From 5c72cce893f9afcd54c2122f1e68d6ed37337b19 Mon Sep 17 00:00:00 2001 From: CalJosKos <120157396+CalJosKos@users.noreply.github.com> Date: Wed, 21 Jan 2026 20:38:29 -0800 Subject: [PATCH 2/3] Bump (#696) --- app.config.ts | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app.config.ts b/app.config.ts index 48c63d853..957fbd993 100644 --- a/app.config.ts +++ b/app.config.ts @@ -53,7 +53,7 @@ export default ({ config }: ConfigContext): ExpoConfig => owner: 'eten-genesis', name: getAppName(appVariant), slug: 'langquest', - version: '2.0.9', + version: '2.0.10', orientation: 'portrait', icon: iconLight, scheme: getScheme(appVariant), diff --git a/package.json b/package.json index 0c33a4546..ad1f72778 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "langquest", "main": "expo-router/entry", - "version": "2.0.9", + "version": "2.0.10", "scripts": { "start": "expo start --dev-client", "start:prod": "expo start --dev-client --no-dev --minify", From 9d99893f5b9a86bdd22c3f410b52263e9225f029 Mon Sep 17 00:00:00 2001 From: CalJosKos <120157396+CalJosKos@users.noreply.github.com> Date: Thu, 22 Jan 2026 12:46:57 -0800 Subject: [PATCH 3/3] Caleb/fix upload and merge (#698) * Fix merge deletion issue, upload orphan patch - Unpublished asset deletions and dependent children happen in a single transaction (to prevent orphans and subsequent fk constraint violations). This includes auto-deleting translations and transcriptions submitted to assets that are deleted in the recording view. - Added referential integrity validation in publishing process. Before publishing content, code checks to make sure all fk-linked records actually exist. If any don't, then the linking record is held onto locally and not published (kicking the can down the road and nervous to automatically delete local content) - STOP DELETION of audio files during asset merge process. Former merge process was deleting audio files from assets being merged into first asset * Slider fix, asset details carousel back - Enable playing translation audio from translation modal - Fixing slider to not cause massive UI delays on assetdetailsview - Add carousel to assetdetails view to see all asset_content_link record content for the asset (not just the first one). Allows user to see and hear all merged content. - Fix navigation crash (out of details view) * Update register view * Fix format, typecheck issues --- components/SourceContent.tsx | 65 ++-- components/ui/slider.tsx | 133 +++++--- database_services/audioSegmentService.ts | 115 +++++-- database_services/publishService.ts | 270 +++++++++++++++- utils/publishUtils.ts | 73 ++++- views/RegisterView.tsx | 1 + views/new/NextGenAssetDetailView.tsx | 295 ++++++++++-------- views/new/NextGenTranslationModalAlt.tsx | 23 +- .../components/RecordingViewSimplified.tsx | 10 +- 9 files changed, 721 insertions(+), 264 deletions(-) diff --git a/components/SourceContent.tsx b/components/SourceContent.tsx index b83675192..df3dc08d8 100644 --- a/components/SourceContent.tsx +++ b/components/SourceContent.tsx @@ -25,33 +25,48 @@ export const SourceContent: React.FC = ({ const { t } = useLocalization(); return ( - - {/* */} - - {sourceLanguage?.native_name || sourceLanguage?.english_name} - - - - {content.text} + + {/* Language name header */} + {sourceLanguage && ( + + {sourceLanguage?.native_name || sourceLanguage?.english_name} + + )} + + {/* Text content - scrollable */} + + + + {content.text} + - {/* */} - - {content.audio && audioSegments ? ( - - ) : content.audio && isLoading ? ( - - - {t('loadingAudio')} - - ) : null} - + + {/* Audio player */} + {(content.audio && audioSegments) || (content.audio && isLoading) ? ( + + {audioSegments ? ( + + ) : ( + + + {t('loadingAudio')} + + )} + + ) : null} ); }; diff --git a/components/ui/slider.tsx b/components/ui/slider.tsx index 004a59f64..d85a3cb4f 100644 --- a/components/ui/slider.tsx +++ b/components/ui/slider.tsx @@ -81,24 +81,38 @@ function Slider({ return isFinite(result) ? result : 0; }, [min, max, clampedValue]); - // Sync dragValue when value prop changes externally (programmatic updates) + // Store min, max, step, animated, and current value in shared values for worklet access + // Using useSharedValue instead of useRef avoids Reanimated serialization warnings + const minShared = useSharedValue(min); + const maxShared = useSharedValue(max); + const stepShared = useSharedValue(step ?? 0); + const animatedShared = useSharedValue(animated); + const currentValueShared = useSharedValue(clampedValue); + + // Sync shared values when props change + React.useEffect(() => { + minShared.value = min; + maxShared.value = max; + stepShared.value = step ?? 0; + animatedShared.value = animated; + }, [ + min, + max, + step, + animated, + minShared, + maxShared, + stepShared, + animatedShared + ]); + + // Sync dragValue and currentValueShared when value prop changes externally (programmatic updates) React.useEffect(() => { if (!isDragging.value) { + currentValueShared.value = clampedValue; dragValue.value = clampedValue; } - }, [clampedValue, isDragging, dragValue]); - - // Store min, max, step, animated in refs for worklet access - const minRef = React.useRef(min); - const maxRef = React.useRef(max); - const stepRef = React.useRef(step); - const animatedRef = React.useRef(animated); - React.useEffect(() => { - minRef.current = min; - maxRef.current = max; - stepRef.current = step; - animatedRef.current = animated; - }, [min, max, step, animated]); + }, [clampedValue, isDragging, dragValue, currentValueShared]); // Handle value change (with step snapping) const handleValueChange = React.useCallback( @@ -119,12 +133,18 @@ function Slider({ [min, max, step, onValueChange] ); - // Store callback in ref for worklet access + // Stable callback wrapper to call onValueChange from worklets via runOnJS + // The ref is accessed on JS thread (inside callOnValueChange), not in the worklet + // This avoids Reanimated serialization warnings from modifying ref.current after capture const onValueChangeRef = React.useRef(handleValueChange); React.useEffect(() => { onValueChangeRef.current = handleValueChange; }, [handleValueChange]); + const callOnValueChange = React.useCallback((value: number) => { + onValueChangeRef.current(value); + }, []); + // Pan gesture for dragging thumb const panGesture = Gesture.Pan() .enabled(!disabled) @@ -133,10 +153,12 @@ function Slider({ .onStart(() => { 'worklet'; isDragging.value = true; - dragValue.value = clampedValue; + // Use currentValueShared (SharedValue) instead of clampedValue (JS value) to avoid serialization warnings + dragValue.value = currentValueShared.value; // Capture starting percent for relative dragging - const range = Math.max(1e-6, maxRef.current - minRef.current); - startPercent.value = ((clampedValue - minRef.current) / range) * 100; + const range = Math.max(1e-6, maxShared.value - minShared.value); + startPercent.value = + ((currentValueShared.value - minShared.value) / range) * 100; }) .onUpdate((event) => { 'worklet'; @@ -151,44 +173,47 @@ function Slider({ ); // Convert to value - const range = maxRef.current - minRef.current; - const rawValue = minRef.current + (newPercent / 100) * range; + const range = maxShared.value - minShared.value; + const rawValue = minShared.value + (newPercent / 100) * range; let newValue = Math.max( - minRef.current, - Math.min(maxRef.current, rawValue) + minShared.value, + Math.min(maxShared.value, rawValue) ); // Apply step snapping using integer arithmetic - if (stepRef.current && stepRef.current > 0) { + if (stepShared.value > 0) { const stepIndex = Math.round( - (newValue - minRef.current) / stepRef.current + (newValue - minShared.value) / stepShared.value + ); + newValue = stepIndex * stepShared.value + minShared.value; + newValue = Math.max( + minShared.value, + Math.min(maxShared.value, newValue) ); - newValue = stepIndex * stepRef.current + minRef.current; - newValue = Math.max(minRef.current, Math.min(maxRef.current, newValue)); } dragValue.value = newValue; - runOnJS(onValueChangeRef.current)(newValue); + runOnJS(callOnValueChange)(newValue); }) .onEnd(() => { 'worklet'; isDragging.value = false; // Snap to final value (with optional spring animation) let finalValue = dragValue.value; - if (stepRef.current && stepRef.current > 0) { + if (stepShared.value > 0) { const stepIndex = Math.round( - (finalValue - minRef.current) / stepRef.current + (finalValue - minShared.value) / stepShared.value ); - finalValue = stepIndex * stepRef.current + minRef.current; + finalValue = stepIndex * stepShared.value + minShared.value; finalValue = Math.max( - minRef.current, - Math.min(maxRef.current, finalValue) + minShared.value, + Math.min(maxShared.value, finalValue) ); } - dragValue.value = animatedRef.current + dragValue.value = animatedShared.value ? withSpring(finalValue, { overshootClamping: true }) : finalValue; - runOnJS(onValueChangeRef.current)(finalValue); + runOnJS(callOnValueChange)(finalValue); }) .onFinalize(() => { 'worklet'; @@ -209,26 +234,29 @@ function Slider({ ); // Convert to value - const range = maxRef.current - minRef.current; - const rawValue = minRef.current + (tapPercent / 100) * range; + const range = maxShared.value - minShared.value; + const rawValue = minShared.value + (tapPercent / 100) * range; let newValue = Math.max( - minRef.current, - Math.min(maxRef.current, rawValue) + minShared.value, + Math.min(maxShared.value, rawValue) ); // Apply step snapping using integer arithmetic - if (stepRef.current && stepRef.current > 0) { + if (stepShared.value > 0) { const stepIndex = Math.round( - (newValue - minRef.current) / stepRef.current + (newValue - minShared.value) / stepShared.value + ); + newValue = stepIndex * stepShared.value + minShared.value; + newValue = Math.max( + minShared.value, + Math.min(maxShared.value, newValue) ); - newValue = stepIndex * stepRef.current + minRef.current; - newValue = Math.max(minRef.current, Math.min(maxRef.current, newValue)); } - dragValue.value = animatedRef.current + dragValue.value = animatedShared.value ? withSpring(newValue, { overshootClamping: true }) : newValue; - runOnJS(onValueChangeRef.current)(newValue); + runOnJS(callOnValueChange)(newValue); }); // Combined gesture (tap on track, pan on thumb) @@ -243,18 +271,19 @@ function Slider({ [trackWidth] ); - // Animated value for display (use dragValue when dragging, otherwise use prop value) + // Animated value for display - always use dragValue since it's synced with clampedValue via useEffect + // Using dragValue (SharedValue) instead of clampedValue (JS value) avoids worklet serialization warnings const displayValue = useDerivedValue(() => { - return isDragging.value ? dragValue.value : clampedValue; - }, [clampedValue]); + return dragValue.value; + }); - // Calculate display percentage + // Calculate display percentage using SharedValues to avoid worklet serialization warnings const displayPercent = useDerivedValue(() => { const val = displayValue.value; - const range = Math.max(1e-6, max - min); - const raw = ((val - min) / range) * 100; + const range = Math.max(1e-6, maxShared.value - minShared.value); + const raw = ((val - minShared.value) / range) * 100; return Math.max(0, Math.min(100, isFinite(raw) ? raw : 0)); - }, [min, max]); + }); // Theme colors (fallback to props if provided) - must be called unconditionally const themePrimary = useThemeColor('primary'); @@ -287,7 +316,7 @@ function Slider({ ); // Always use direct value if animations are disabled or during dragging - if (!animatedRef.current || isDragging.value) { + if (!animatedShared.value || isDragging.value) { return { left: `${leftPercent}%` }; diff --git a/database_services/audioSegmentService.ts b/database_services/audioSegmentService.ts index 6654deac1..a86597b82 100644 --- a/database_services/audioSegmentService.ts +++ b/database_services/audioSegmentService.ts @@ -135,42 +135,109 @@ export class AudioSegmentService { } /** - * Delete audio segment and associated files + * Delete audio segment and all associated records (including child translations) + * + * Deletion order (children before parents to maintain referential integrity): + * 1. Find all child assets (translations/transcriptions with source_asset_id = this asset) + * 2. For each child asset: delete votes, asset_tag_links, quest_asset_links, asset_content_links, then the asset + * 3. For the parent asset: delete votes, asset_tag_links, quest_asset_links, asset_content_links, then the asset + * + * @param assetId - The ID of the asset to delete + * @param options.preserveAudioFiles - If true, skip deleting audio files from attachment queue. + * Use this when merging assets, where audio files are being transferred to another asset. */ - async deleteAudioSegment(assetId: string): Promise { + async deleteAudioSegment( + assetId: string, + options?: { preserveAudioFiles?: boolean } + ): Promise { + const { preserveAudioFiles = false } = options ?? {}; try { - // Get asset content to find audio file - const assetContent = await db + const resolvedAsset = resolveTable('asset', { localOverride: true }); + const resolvedAssetContent = resolveTable('asset_content_link', { + localOverride: true + }); + const resolvedQuestAssetLink = resolveTable('quest_asset_link', { + localOverride: true + }); + const resolvedVote = resolveTable('vote', { localOverride: true }); + const resolvedAssetTagLink = resolveTable('asset_tag_link', { + localOverride: true + }); + + // Find all child assets (translations/transcriptions) that reference this asset + const childAssets = await system.db + .select({ id: resolvedAsset.id }) + .from(resolvedAsset) + .where(eq(resolvedAsset.source_asset_id, assetId)); + + // Collect all asset IDs to delete (children + parent) + const allAssetIds = [...childAssets.map((c) => c.id), assetId]; + + // Collect audio files to delete from attachment queue BEFORE transaction + const allAssetContent = await db .select() .from(asset_content_link) .where(eq(asset_content_link.asset_id, assetId)); - // Delete audio files - for (const content of assetContent) { - if (content.audio) { - for (const audio of content.audio) { - await system.permAttachmentQueue?.deleteFromQueue(audio); + // Also get content from child assets + for (const child of childAssets) { + const childContent = await db + .select() + .from(asset_content_link) + .where(eq(asset_content_link.asset_id, child.id)); + allAssetContent.push(...childContent); + } + + // Delete audio files from queue (file system ops, outside transaction) + // SKIP if preserveAudioFiles is true (used during merge when audio is being transferred) + if (!preserveAudioFiles) { + for (const content of allAssetContent) { + if (content.audio) { + for (const audio of content.audio) { + await system.permAttachmentQueue?.deleteFromQueue(audio); + } } } + } else { + console.log( + `⏭️ Preserving ${allAssetContent.length} audio files (merge operation)` + ); } - const resolvedAsset = resolveTable('asset', { localOverride: true }); - const resolvedAssetContent = resolveTable('asset_content_link', { - localOverride: true - }); - const resolvedQuestAssetLink = resolveTable('quest_asset_link', { - localOverride: true + // CRITICAL: Delete all related records in a single transaction + // Order matters: delete children before parent to maintain referential integrity + await system.db.transaction(async (tx) => { + for (const currentAssetId of allAssetIds) { + // 1. Delete votes (references asset_id) + await tx + .delete(resolvedVote) + .where(eq(resolvedVote.asset_id, currentAssetId)); + + // 2. Delete asset_tag_links (references asset_id) + await tx + .delete(resolvedAssetTagLink) + .where(eq(resolvedAssetTagLink.asset_id, currentAssetId)); + + // 3. Delete quest_asset_links (references asset_id) + await tx + .delete(resolvedQuestAssetLink) + .where(eq(resolvedQuestAssetLink.asset_id, currentAssetId)); + + // 4. Delete asset_content_links (references asset_id) + await tx + .delete(resolvedAssetContent) + .where(eq(resolvedAssetContent.asset_id, currentAssetId)); + + // 5. Delete the asset itself (must be last for this asset) + await tx + .delete(resolvedAsset) + .where(eq(resolvedAsset.id, currentAssetId)); + } }); - await system.db - .delete(resolvedAsset) - .where(eq(resolvedAsset.id, assetId)); - await system.db - .delete(resolvedAssetContent) - .where(eq(resolvedAssetContent.asset_id, assetId)); - await system.db - .delete(resolvedQuestAssetLink) - .where(eq(resolvedQuestAssetLink.asset_id, assetId)); + console.log( + `✅ Deleted asset ${assetId.slice(0, 8)} and ${childAssets.length} child assets` + ); } catch (error) { console.error('Failed to delete audio segment:', error); throw error; diff --git a/database_services/publishService.ts b/database_services/publishService.ts index b4cd6a881..f10ae613d 100644 --- a/database_services/publishService.ts +++ b/database_services/publishService.ts @@ -235,6 +235,254 @@ interface ValidationResult { warnings: string[]; } +interface ReferentialIntegrityResult { + filteredData: ChapterData; + orphanedRecords: { + questAssetLinks: string[]; + translationQuestAssetLinks: string[]; + assetContentLinks: string[]; + translationContentLinks: string[]; + questTagLinks: string[]; + assetTagLinks: string[]; + childAssets: string[]; + }; + warnings: string[]; +} + +// ============================================================================ +// REFERENTIAL INTEGRITY VALIDATION +// ============================================================================ + +/** + * Validate referential integrity of all records before publishing. + * Filter out orphan records (where referenced entities don't exist) but preserve them locally. + * This prevents FK constraint violations during upload while keeping data for investigation. + * + * Checks: + * - quest_asset_link: quest_id and asset_id must exist + * - asset_content_link: asset_id must exist + * - quest_tag_link: quest_id and tag_id must exist + * - asset_tag_link: asset_id and tag_id must exist + * - child assets: source_asset_id must exist + */ +function validateReferentialIntegrity( + data: ChapterData +): ReferentialIntegrityResult { + console.log('🔍 Validating referential integrity...'); + + const warnings: string[] = []; + const orphanedRecords = { + questAssetLinks: [] as string[], + translationQuestAssetLinks: [] as string[], + assetContentLinks: [] as string[], + translationContentLinks: [] as string[], + questTagLinks: [] as string[], + assetTagLinks: [] as string[], + childAssets: [] as string[] + }; + + // Build sets of valid IDs for efficient lookup + const validQuestIds = new Set([data.chapter.id]); + if (data.parentBook) { + validQuestIds.add(data.parentBook.id); + } + + const validAssetIds = new Set(data.assets.map((a) => a.id)); + const validTagIds = new Set((data.tags ?? []).map((t) => t.id)); + + // 1. Validate quest_asset_links + const validQuestAssetLinks = data.questAssetLinks.filter((link) => { + const questExists = validQuestIds.has(link.quest_id); + const assetExists = validAssetIds.has(link.asset_id); + + if (!questExists || !assetExists) { + orphanedRecords.questAssetLinks.push(link.id); + if (!questExists) { + warnings.push( + `⚠️ Orphan quest_asset_link ${link.id.slice(0, 8)}: quest_id ${link.quest_id.slice(0, 8)} not found` + ); + } + if (!assetExists) { + warnings.push( + `⚠️ Orphan quest_asset_link ${link.id.slice(0, 8)}: asset_id ${link.asset_id.slice(0, 8)} not found` + ); + } + return false; + } + return true; + }); + + // 2. Validate translation assets (source_asset_id must exist) + const validTranslationAssets = (data.translationAssets ?? []).filter( + (asset) => { + if (asset.source_asset_id && !validAssetIds.has(asset.source_asset_id)) { + orphanedRecords.childAssets.push(asset.id); + warnings.push( + `⚠️ Orphan translation asset ${asset.id.slice(0, 8)}: source_asset_id ${asset.source_asset_id.slice(0, 8)} not found` + ); + return false; + } + return true; + } + ); + + // Build valid translation asset IDs set (after filtering) + const validTranslationAssetIds = new Set( + validTranslationAssets.map((a) => a.id) + ); + const allValidAssetIds = new Set([ + ...validAssetIds, + ...validTranslationAssetIds + ]); + + // 3. Validate translation quest_asset_links + const validTranslationQuestAssetLinks = ( + data.translationQuestAssetLinks ?? [] + ).filter((link) => { + const questExists = validQuestIds.has(link.quest_id); + const assetExists = allValidAssetIds.has(link.asset_id); + + if (!questExists || !assetExists) { + orphanedRecords.translationQuestAssetLinks.push(link.id); + if (!questExists) { + warnings.push( + `⚠️ Orphan translation quest_asset_link ${link.id.slice(0, 8)}: quest_id ${link.quest_id.slice(0, 8)} not found` + ); + } + if (!assetExists) { + warnings.push( + `⚠️ Orphan translation quest_asset_link ${link.id.slice(0, 8)}: asset_id ${link.asset_id.slice(0, 8)} not found` + ); + } + return false; + } + return true; + }); + + // 4. Validate asset_content_links + const validAssetContentLinks = data.assetContentLinks.filter((link) => { + if (!allValidAssetIds.has(link.asset_id)) { + orphanedRecords.assetContentLinks.push(link.id); + warnings.push( + `⚠️ Orphan asset_content_link ${link.id.slice(0, 8)}: asset_id ${link.asset_id.slice(0, 8)} not found` + ); + return false; + } + return true; + }); + + // 5. Validate translation content links + const validTranslationContentLinks = ( + data.translationContentLinks ?? [] + ).filter((link) => { + if (!allValidAssetIds.has(link.asset_id)) { + orphanedRecords.translationContentLinks.push(link.id); + warnings.push( + `⚠️ Orphan translation content_link ${link.id.slice(0, 8)}: asset_id ${link.asset_id.slice(0, 8)} not found` + ); + return false; + } + return true; + }); + + // 6. Validate quest_tag_links + const validQuestTagLinks = (data.questTagLinks ?? []).filter((link) => { + const questExists = validQuestIds.has(link.quest_id); + const tagExists = validTagIds.has(link.tag_id); + + if (!questExists || !tagExists) { + orphanedRecords.questTagLinks.push(link.id); + if (!questExists) { + warnings.push( + `⚠️ Orphan quest_tag_link ${link.id.slice(0, 8)}: quest_id ${link.quest_id.slice(0, 8)} not found` + ); + } + if (!tagExists) { + warnings.push( + `⚠️ Orphan quest_tag_link ${link.id.slice(0, 8)}: tag_id ${link.tag_id.slice(0, 8)} not found` + ); + } + return false; + } + return true; + }); + + // 7. Validate asset_tag_links + const validAssetTagLinks = (data.assetTagLinks ?? []).filter((link) => { + const assetExists = allValidAssetIds.has(link.asset_id); + const tagExists = validTagIds.has(link.tag_id); + + if (!assetExists || !tagExists) { + orphanedRecords.assetTagLinks.push(link.id); + if (!assetExists) { + warnings.push( + `⚠️ Orphan asset_tag_link ${link.id.slice(0, 8)}: asset_id ${link.asset_id.slice(0, 8)} not found` + ); + } + if (!tagExists) { + warnings.push( + `⚠️ Orphan asset_tag_link ${link.id.slice(0, 8)}: tag_id ${link.tag_id.slice(0, 8)} not found` + ); + } + return false; + } + return true; + }); + + // Log summary + const totalOrphans = + orphanedRecords.questAssetLinks.length + + orphanedRecords.translationQuestAssetLinks.length + + orphanedRecords.assetContentLinks.length + + orphanedRecords.translationContentLinks.length + + orphanedRecords.questTagLinks.length + + orphanedRecords.assetTagLinks.length + + orphanedRecords.childAssets.length; + + if (totalOrphans > 0) { + console.warn( + `⚠️ Found ${totalOrphans} orphan records that will be skipped during publish:` + ); + console.warn( + ` - quest_asset_links: ${orphanedRecords.questAssetLinks.length}` + ); + console.warn( + ` - translation quest_asset_links: ${orphanedRecords.translationQuestAssetLinks.length}` + ); + console.warn( + ` - asset_content_links: ${orphanedRecords.assetContentLinks.length}` + ); + console.warn( + ` - translation content_links: ${orphanedRecords.translationContentLinks.length}` + ); + console.warn( + ` - quest_tag_links: ${orphanedRecords.questTagLinks.length}` + ); + console.warn( + ` - asset_tag_links: ${orphanedRecords.assetTagLinks.length}` + ); + console.warn(` - child assets: ${orphanedRecords.childAssets.length}`); + console.warn(' These records are preserved locally for investigation.'); + } else { + console.log('✅ All records have valid references'); + } + + return { + filteredData: { + ...data, + questAssetLinks: validQuestAssetLinks, + translationAssets: validTranslationAssets, + translationQuestAssetLinks: validTranslationQuestAssetLinks, + assetContentLinks: validAssetContentLinks, + translationContentLinks: validTranslationContentLinks, + questTagLinks: validQuestTagLinks, + assetTagLinks: validAssetTagLinks + }, + orphanedRecords, + warnings + }; +} + // ============================================================================ // STEP 1: GATHER ALL CHAPTER DATA // ============================================================================ @@ -1693,12 +1941,23 @@ export async function publishBibleChapter( }; } + // STEP 2b: Validate referential integrity and filter orphan records + // This prevents FK constraint violations by skipping records whose referenced entities don't exist + const integrityResult = validateReferentialIntegrity(chapterData); + const filteredChapterData = integrityResult.filteredData; + + if (integrityResult.warnings.length > 0) { + console.warn( + `⚠️ Referential integrity check found ${integrityResult.warnings.length} issues - orphan records will be skipped` + ); + } + // STEP 3: Ensure parent book exists in Supabase FIRST (bypass PowerSync for ordering) // This prevents foreign key constraint violations during chapter upload - await ensureParentBookInSupabase(chapterData); + await ensureParentBookInSupabase(filteredChapterData); // STEP 4: Ensure audio files are uploading - const pendingAttachments = await ensureAudioUploaded(chapterData); + const pendingAttachments = await ensureAudioUploaded(filteredChapterData); if (pendingAttachments > 0) { console.log(`⏳ ${pendingAttachments} audio files still uploading...`); @@ -1706,17 +1965,18 @@ export async function publishBibleChapter( // STEP 5A: Execute publish transaction (WITHOUT asset_content_link) // Source assets are inserted before child assets within the transaction - await executePublishTransaction(chapterData, userId, false); + // Uses filteredChapterData to skip orphan records + await executePublishTransaction(filteredChapterData, userId, false); // STEP 5B: Wait for critical dependencies to reach Supabase // This ensures RLS policies have everything they need before inserting content links console.log('⏳ Waiting for dependencies to sync to Supabase...'); - await waitForCriticalDependencies(chapterData, userId); + await waitForCriticalDependencies(filteredChapterData, userId); // STEP 5C: Insert asset_content_link records // Dependencies are now confirmed in Supabase console.log('🔗 Inserting asset_content_link records...'); - await executePublishTransaction(chapterData, userId, true); + await executePublishTransaction(filteredChapterData, userId, true); console.log('\n✅ CHAPTER PUBLISH COMPLETE'); console.log('📡 PowerSync is uploading to cloud in background...'); diff --git a/utils/publishUtils.ts b/utils/publishUtils.ts index 76280fc7b..6872cb8a4 100644 --- a/utils/publishUtils.ts +++ b/utils/publishUtils.ts @@ -306,12 +306,33 @@ export async function publishQuest(questId: string, projectId: string) { await tx.run(sql.raw(sourceAssetQuery)); // Step 4b: Insert CHILD assets second (translations/transcriptions with source_asset_id) - const childAssetQuery = `INSERT OR IGNORE INTO asset_synced(${assetColumns}) SELECT ${assetColumns} FROM asset_local WHERE id IN (${toColumns(nestedAssetIds)}) AND source = 'local' AND source_asset_id IS NOT NULL`; + // CRITICAL: Only insert child assets where the source_asset_id actually exists + const childAssetQuery = `INSERT OR IGNORE INTO asset_synced(${assetColumns}) + SELECT ${assetColumns + .split(', ') + .map((c) => `child.${c}`) + .join(', ')} + FROM asset_local child + WHERE child.id IN (${toColumns(nestedAssetIds)}) + AND child.source = 'local' + AND child.source_asset_id IS NOT NULL + AND EXISTS (SELECT 1 FROM asset_local parent WHERE parent.id = child.source_asset_id)`; console.log('childAssetQuery', childAssetQuery); await tx.run(sql.raw(childAssetQuery)); + // CRITICAL: Only insert quest_asset_links where the referenced asset actually exists + // This prevents FK constraint violations when PowerSync uploads to Supabase const questAssetLinkColumns = getTableColumns(quest_asset_link_synced); - const questAssetLinkQuery = `INSERT OR IGNORE INTO quest_asset_link_synced(${questAssetLinkColumns}) SELECT ${questAssetLinkColumns} FROM quest_asset_link_local WHERE quest_id IN (${toColumns(allQuestIds)}) AND source = 'local'`; + const questAssetLinkQuery = `INSERT OR IGNORE INTO quest_asset_link_synced(${questAssetLinkColumns}) + SELECT ${questAssetLinkColumns + .split(', ') + .map((c) => `qal.${c}`) + .join(', ')} + FROM quest_asset_link_local qal + WHERE qal.quest_id IN (${toColumns(allQuestIds)}) + AND qal.source = 'local' + AND EXISTS (SELECT 1 FROM asset_local a WHERE a.id = qal.asset_id)`; + console.log('questAssetLinkQuery', questAssetLinkQuery); await tx.run(sql.raw(questAssetLinkQuery)); const assetContentLinkColumns = getTableColumns( @@ -336,10 +357,24 @@ export async function publishQuest(questId: string, projectId: string) { .map(getLocalAttachmentUri) // without OPFS ); - const assetContentLinkQuery = `INSERT OR IGNORE INTO asset_content_link_synced(${assetContentLinkColumns}) SELECT ${assetContentLinkColumns.replace( - `audio,`, - `REPLACE(audio, 'local/', '') AS audio,` - )} FROM asset_content_link_local WHERE asset_id IN (${toColumns(nestedAssetIds)}) AND source = 'local'`; + // CRITICAL: Only insert asset_content_links where the referenced asset actually exists + // Build prefixed columns manually to avoid splitting issues with REPLACE function + const aclColumnsPrefixed = assetContentLinkColumns + .split(', ') + .map((col) => { + if (col === 'audio') { + // Special handling for audio column - strip 'local/' prefix + return `REPLACE(acl.audio, 'local/', '') AS audio`; + } + return `acl.${col}`; + }) + .join(', '); + const assetContentLinkQuery = `INSERT OR IGNORE INTO asset_content_link_synced(${assetContentLinkColumns}) + SELECT ${aclColumnsPrefixed} + FROM asset_content_link_local acl + WHERE acl.asset_id IN (${toColumns(nestedAssetIds)}) + AND acl.source = 'local' + AND EXISTS (SELECT 1 FROM asset_local a WHERE a.id = acl.asset_id)`; console.log('assetContentLinkQuery', assetContentLinkQuery); await tx.run(sql.raw(assetContentLinkQuery)); @@ -413,12 +448,34 @@ export async function publishQuest(questId: string, projectId: string) { const tagQuery = `INSERT OR IGNORE INTO tag_synced(${tagColumns}) SELECT ${tagColumns} FROM tag_local WHERE id IN (${toColumns(tagsToPublish)}) AND source = 'local'`; await tx.run(sql.raw(tagQuery)); + // CRITICAL: Only insert quest_tag_links where both quest and tag exist const questTagLinkColumns = getTableColumns(quest_tag_link_synced); - const questTagLinkQuery = `INSERT OR IGNORE INTO quest_tag_link_synced(${questTagLinkColumns}) SELECT ${questTagLinkColumns} FROM quest_tag_link_local WHERE quest_id IN (${toColumns(allQuestIds)}) AND source = 'local'`; + const questTagLinkQuery = `INSERT OR IGNORE INTO quest_tag_link_synced(${questTagLinkColumns}) + SELECT ${questTagLinkColumns + .split(', ') + .map((c) => `qtl.${c}`) + .join(', ')} + FROM quest_tag_link_local qtl + WHERE qtl.quest_id IN (${toColumns(allQuestIds)}) + AND qtl.source = 'local' + AND EXISTS (SELECT 1 FROM quest_local q WHERE q.id = qtl.quest_id) + AND EXISTS (SELECT 1 FROM tag_local t WHERE t.id = qtl.tag_id)`; + console.log('questTagLinkQuery', questTagLinkQuery); await tx.run(sql.raw(questTagLinkQuery)); + // CRITICAL: Only insert asset_tag_links where both asset and tag exist const assetTagLinkColumns = getTableColumns(asset_tag_link_synced); - const assetTagLinkQuery = `INSERT OR IGNORE INTO asset_tag_link_synced(${assetTagLinkColumns}) SELECT ${assetTagLinkColumns} FROM asset_tag_link_local WHERE asset_id IN (${toColumns(nestedAssetIds)}) AND source = 'local'`; + const assetTagLinkQuery = `INSERT OR IGNORE INTO asset_tag_link_synced(${assetTagLinkColumns}) + SELECT ${assetTagLinkColumns + .split(', ') + .map((c) => `atl.${c}`) + .join(', ')} + FROM asset_tag_link_local atl + WHERE atl.asset_id IN (${toColumns(nestedAssetIds)}) + AND atl.source = 'local' + AND EXISTS (SELECT 1 FROM asset_local a WHERE a.id = atl.asset_id) + AND EXISTS (SELECT 1 FROM tag_local t WHERE t.id = atl.tag_id)`; + console.log('assetTagLinkQuery', assetTagLinkQuery); await tx.run(sql.raw(assetTagLinkQuery)); console.log('localAudioFilesForAssets', localAudioFilesForAssets); diff --git a/views/RegisterView.tsx b/views/RegisterView.tsx index 0633ddcf8..cb65b98ab 100644 --- a/views/RegisterView.tsx +++ b/views/RegisterView.tsx @@ -121,6 +121,7 @@ export default function RegisterView({ const form = useForm>({ resolver: zodResolver(formSchema), + mode: 'onChange', // Validate as user types so isValid updates in real-time defaultValues: { email: sharedAuthInfo?.email || '', password: '', diff --git a/views/new/NextGenAssetDetailView.tsx b/views/new/NextGenAssetDetailView.tsx index 8f3b77e50..4188a453c 100644 --- a/views/new/NextGenAssetDetailView.tsx +++ b/views/new/NextGenAssetDetailView.tsx @@ -50,12 +50,11 @@ import { LockIcon, PlusIcon, SettingsIcon, - UserIcon, - Volume2Icon, - VolumeXIcon + UserIcon } from 'lucide-react-native'; -import React, { useEffect, useState } from 'react'; -import { Dimensions, Text, View } from 'react-native'; +import React, { useEffect, useRef, useState } from 'react'; +import type { FlatList as FlatListType, ViewToken } from 'react-native'; +import { Dimensions, FlatList, Text, View } from 'react-native'; import { scheduleOnRN } from 'react-native-worklets'; import NextGenNewTranslationModal from './NextGenNewTranslationModal'; import NextGenTranslationsList from './NextGenTranslationsList'; @@ -426,11 +425,13 @@ export default function NextGenAssetDetailView() { // Active tab is now derived from asset content via useMemo above - // Reset content index when asset changes + // Reset content index and scroll position when asset changes // Use queueMicrotask to defer state update and avoid cascading renders useEffect(() => { scheduleOnRN(() => { setCurrentContentIndex(0); + // Also scroll the FlatList to the first item + contentFlatListRef.current?.scrollToIndex({ index: 0, animated: false }); }); }, [currentAssetId]); @@ -580,13 +581,51 @@ export default function NextGenAssetDetailView() { isAuthenticated ]); - console.log('resolvedAudioUris', resolvedAudioUris); - const { hasReported, isLoading: isReportLoading } = useHasUserReported( currentAssetId || '', 'assets' ); + // FlatList ref for programmatic scrolling - must be before any early returns + const contentFlatListRef = + useRef>(null); + + // Viewability config for FlatList - must be before any early returns + const viewabilityConfig = useRef({ + itemVisiblePercentThreshold: 50 + }).current; + + // Handle viewable items change (when user swipes) - must be before any early returns + const onViewableItemsChanged = useRef( + ({ viewableItems }: { viewableItems: ViewToken[] }) => { + const firstItem = viewableItems[0]; + if (firstItem?.index != null) { + setCurrentContentIndex(firstItem.index); + } + } + ).current; + + // Screen dimensions for layout calculations + const screenHeight = Dimensions.get('window').height; + const screenWidth = Dimensions.get('window').width; + const assetViewerHeight = screenHeight * ASSET_VIEWER_PROPORTION; + // Content width for FlatList paging (full width minus padding) + const contentWidth = screenWidth - 32; // 16px padding on each side + + // Scroll to a specific content index + const scrollToContentIndex = (index: number) => { + if (contentFlatListRef.current && activeAsset?.content) { + const clampedIndex = Math.max( + 0, + Math.min(index, activeAsset.content.length - 1) + ); + contentFlatListRef.current.scrollToIndex({ + index: clampedIndex, + animated: true + }); + } + }; + if (!currentAssetId) { return ( @@ -599,9 +638,6 @@ export default function NextGenAssetDetailView() { ); } - const screenHeight = Dimensions.get('window').height; - const assetViewerHeight = screenHeight * ASSET_VIEWER_PROPORTION; - // Show loading skeleton if we're loading OR if we don't have asset data yet for the current asset // This prevents the "not available" flash when navigating between assets if (isAssetLoading || (!activeAsset && currentAssetId)) { @@ -840,137 +876,122 @@ export default function NextGenAssetDetailView() { className={cn(!allowEditing && 'opacity-50', 'flex overflow-hidden')} style={{ height: assetViewerHeight }} > - + {activeAsset.content && activeAsset.content.length > 0 ? ( - - {/* Current content item */} - {activeAsset.content[currentContentIndex] && ( - - { - const content = - activeAsset.content[currentContentIndex]; - if (!content) return null; - const languoidId = - content.languoid_id || content.source_language_id; - const languoid = languoidId - ? (languoidById.get(languoidId) ?? null) - : null; - // TODO: Update SourceContent to accept Languoid type - // For now, use type assertion to handle transition - return languoid as any; - })()} - audioSegments={resolvedAudioUris} - isLoading={isLoadingAttachments} - onTranscribe={ - enableTranscription && isAuthenticated - ? handleTranscribe - : undefined + + {/* Swipeable content carousel */} + item.id} + onViewableItemsChanged={onViewableItemsChanged} + viewabilityConfig={viewabilityConfig} + snapToInterval={contentWidth} + decelerationRate="fast" + getItemLayout={(_, index) => ({ + length: contentWidth, + offset: contentWidth * index, + index + })} + style={{ height: 200 }} + renderItem={({ item: content, index }) => { + const languoidId = + content.languoid_id || content.source_language_id; + const languoid = languoidId + ? (languoidById.get(languoidId) ?? null) + : null; + const isCurrentItem = index === currentContentIndex; + + return ( + + + + ); + }} + /> + + {/* Navigation controls and pagination - only show if multiple items */} + {activeAsset.content.length > 1 && ( + + + + {/* Pagination dots */} + + {activeAsset.content.map((_, index) => ( - {/* Navigation buttons - only show if multiple items */} - {activeAsset.content.length > 1 ? ( - - - - - {currentContentIndex + 1} /{' '} - {activeAsset.content.length} - - - - - ) : ( - + key={index} + className={cn( + 'h-2 w-2 rounded-full', + index === currentContentIndex + ? 'bg-primary' + : 'bg-muted-foreground/30' )} - - ) : null} + /> + ))} + + )} diff --git a/views/new/NextGenTranslationModalAlt.tsx b/views/new/NextGenTranslationModalAlt.tsx index 83d9a4413..a505590f7 100644 --- a/views/new/NextGenTranslationModalAlt.tsx +++ b/views/new/NextGenTranslationModalAlt.tsx @@ -25,7 +25,7 @@ import { useTranscription } from '@/hooks/useTranscription'; import { useLocalStore } from '@/store/localStore'; import { resolveTable } from '@/utils/dbUtils'; import { SHOW_DEV_ELEMENTS } from '@/utils/featureFlags'; -import { fileExists, getLocalAttachmentUriWithOPFS } from '@/utils/fileUtils'; +import { fileExists, getLocalUri } from '@/utils/fileUtils'; import { cn, getThemeColor } from '@/utils/styleUtils'; import RNAlert from '@blazejkustra/react-native-alert'; import { Ionicons } from '@expo/vector-icons'; @@ -253,7 +253,7 @@ export default function NextGenTranslationModal({ const [audioSegments, setAudioSegments] = useState([]); useEffect(() => { - const loadAudioSegments = async () => { + const loadAudioSegments = () => { if (!asset?.content) { setAudioSegments([]); return; @@ -261,16 +261,17 @@ export default function NextGenTranslationModal({ const audioIds = asset.content .flatMap((c) => c.audio ?? []) .filter(Boolean); - const segments = await Promise.all( - audioIds.map((audio) => - getLocalAttachmentUriWithOPFS( - attachmentStates.get(audio)?.local_uri ?? '' - ) - ) - ); - setAudioSegments(segments.filter(Boolean)); + // Use getLocalUri directly - local_uri already includes the path prefix + // Only include segments where attachmentStates has a valid local_uri + const segments = audioIds + .map((audio) => { + const localUri = attachmentStates.get(audio)?.local_uri; + return localUri ? getLocalUri(localUri) : null; + }) + .filter((uri): uri is string => uri !== null); + setAudioSegments(segments); }; - void loadAudioSegments(); + loadAudioSegments(); }, [asset?.content, attachmentStates]); const isOwnTranslation = currentUser?.id === asset?.creator_id; diff --git a/views/new/recording/components/RecordingViewSimplified.tsx b/views/new/recording/components/RecordingViewSimplified.tsx index f9a216c32..5632c7b32 100644 --- a/views/new/recording/components/RecordingViewSimplified.tsx +++ b/views/new/recording/components/RecordingViewSimplified.tsx @@ -1727,7 +1727,10 @@ const RecordingViewSimplified = ({ }); } - await audioSegmentService.deleteAudioSegment(second.id); + // CRITICAL: Preserve audio files when merging - they are now linked to the first asset + await audioSegmentService.deleteAudioSegment(second.id, { + preserveAudioFiles: true + }); // Force re-load of segment count for the merged asset debugLog( @@ -1803,7 +1806,10 @@ const RecordingViewSimplified = ({ }); } - await audioSegmentService.deleteAudioSegment(src.id); + // CRITICAL: Preserve audio files when merging - they are now linked to the target asset + await audioSegmentService.deleteAudioSegment(src.id, { + preserveAudioFiles: true + }); } // Force re-load of segment count for the merged target asset