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/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/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/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..d85a3cb4f 100644 --- a/components/ui/slider.tsx +++ b/components/ui/slider.tsx @@ -1,117 +1,388 @@ -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]); + + // 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, currentValueShared]); + + // 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 }; + // 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]); -interface NativeIndicatorProps { - percent: number; -} + const callOnValueChange = React.useCallback((value: number) => { + onValueChangeRef.current(value); + }, []); + + // 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; + // 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, maxShared.value - minShared.value); + startPercent.value = + ((currentValueShared.value - minShared.value) / 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 = maxShared.value - minShared.value; + const rawValue = minShared.value + (newPercent / 100) * range; + let newValue = Math.max( + minShared.value, + Math.min(maxShared.value, rawValue) + ); + + // Apply step snapping using integer arithmetic + if (stepShared.value > 0) { + const stepIndex = Math.round( + (newValue - minShared.value) / stepShared.value + ); + newValue = stepIndex * stepShared.value + minShared.value; + newValue = Math.max( + minShared.value, + Math.min(maxShared.value, newValue) + ); + } + + dragValue.value = newValue; + runOnJS(callOnValueChange)(newValue); + }) + .onEnd(() => { + 'worklet'; + isDragging.value = false; + // Snap to final value (with optional spring animation) + let finalValue = dragValue.value; + if (stepShared.value > 0) { + const stepIndex = Math.round( + (finalValue - minShared.value) / stepShared.value + ); + finalValue = stepIndex * stepShared.value + minShared.value; + finalValue = Math.max( + minShared.value, + Math.min(maxShared.value, finalValue) + ); + } + dragValue.value = animatedShared.value + ? withSpring(finalValue, { overshootClamping: true }) + : finalValue; + runOnJS(callOnValueChange)(finalValue); + }) + .onFinalize(() => { + 'worklet'; + isDragging.value = false; + }); -function NativeRange({ percent }: NativeIndicatorProps) { - const progress = useDerivedValue(() => percent); + // Tap gesture for track tapping + const tapGesture = Gesture.Tap() + .enabled(!disabled) + .onEnd((event) => { + 'worklet'; + if (trackWidth.value === 0) return; - const animatedStyle = useAnimatedStyle(() => { + // Calculate tap position relative to track + const tapPercent = Math.max( + 0, + Math.min(100, (event.x / trackWidth.value) * 100) + ); + + // Convert to value + const range = maxShared.value - minShared.value; + const rawValue = minShared.value + (tapPercent / 100) * range; + let newValue = Math.max( + minShared.value, + Math.min(maxShared.value, rawValue) + ); + + // Apply step snapping using integer arithmetic + if (stepShared.value > 0) { + const stepIndex = Math.round( + (newValue - minShared.value) / stepShared.value + ); + newValue = stepIndex * stepShared.value + minShared.value; + newValue = Math.max( + minShared.value, + Math.min(maxShared.value, newValue) + ); + } + + dragValue.value = animatedShared.value + ? withSpring(newValue, { overshootClamping: true }) + : newValue; + runOnJS(callOnValueChange)(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 - 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 dragValue.value; + }); + + // Calculate display percentage using SharedValues to avoid worklet serialization warnings + const displayPercent = useDerivedValue(() => { + const val = displayValue.value; + 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)); + }); + + // 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 (!animatedShared.value || 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/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/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..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", @@ -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/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/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..5632c7b32 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, @@ -1715,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( @@ -1791,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 @@ -2192,6 +2210,7 @@ const RecordingViewSimplified = ({ energyShared={energyShared} vadThreshold={vadThreshold} isRecordingShared={isRecordingShared} + isDiscardedShared={isDiscardedShared} onCancel={() => { // Cancel VAD mode setIsVADLocked(false); @@ -2297,6 +2316,7 @@ const RecordingViewSimplified = ({ vadThreshold={vadThreshold} energyShared={energyShared} isRecordingShared={isRecordingShared} + isDiscardedShared={isDiscardedShared} displayMode={vadDisplayMode} /> )} @@ -2329,6 +2349,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 }; }