Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion blotztask-mobile/assets/animations/voice-wave.json

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
requestRecordingPermissionsAsync,
setAudioModeAsync,
useAudioRecorder,
useAudioRecorderState,
} from "expo-audio";
import { transcribeAudioFile } from "../services/speech-transcription-service";

Expand All @@ -29,7 +30,11 @@ export const SpeechInput = ({
const { t } = useTranslation(["aiTaskGenerate", "common"]);
const [isListening, setIsListening] = useState(false);
const [isUploadingAudio, setIsUploadingAudio] = useState(false);
const recorder = useAudioRecorder(RecordingPresets.HIGH_QUALITY);
const recorder = useAudioRecorder({
...RecordingPresets.HIGH_QUALITY,
isMeteringEnabled: true,
});
const recorderState = useAudioRecorderState(recorder, 40);

const startListening = async () => {
try {
Expand Down Expand Up @@ -124,6 +129,7 @@ export const SpeechInput = ({
<ErrorMessageCard errorMessage={aiGeneratedMessage.errorMessage} />
)}
<VoiceInputButton
micLevel={recorderState.metering ?? 0}
isListening={isListening}
startListening={startListening}
abortListening={abortListening}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,12 @@ import React from "react";
import { VoiceTimer } from "./voice-timer";
import { MaterialCommunityIcons } from "@expo/vector-icons";
import { LinearGradient } from "expo-linear-gradient";
import LottieView from "lottie-react-native";
import { ASSETS } from "@/shared/constants/assets";
import { useTranslation } from "react-i18next";
import MaskedView from "@react-native-masked-view/masked-view";
import VoiceLevelAnimation from "./voice-level-animation";

type Props = {
micLevel: number;
isListening: boolean;
startListening: () => void;
abortListening: () => void;
Expand Down Expand Up @@ -53,6 +53,7 @@ const GradiantMicIcon = () => {
};

const VoiceInputButton = ({
micLevel,
isListening,
startListening,
abortListening,
Expand Down Expand Up @@ -95,13 +96,7 @@ const VoiceInputButton = ({
<MaterialCommunityIcons name="trash-can-outline" size={22} color="#A3DC2F" />
</Pressable>
<View className="flex-1 items-center justify-center mx-2">
<LottieView
source={ASSETS.voiceWave}
loop={true}
autoPlay={true}
style={{ width: "100%", height: 40 }}
resizeMode="contain"
></LottieView>
<VoiceLevelAnimation level={micLevel} />
</View>
<VoiceTimer />
<Pressable
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
import React, { useEffect } from "react";
import { View, type ViewStyle } from "react-native";
import Animated, {
Easing,
interpolate,
type SharedValue,
withRepeat,
useAnimatedStyle,
useSharedValue,
withTiming,
} from "react-native-reanimated";

type VoiceLevelAnimationProps = {
level?: number;
bars?: number;
color?: string;
height?: number;
width?: number | `${number}%`;
style?: ViewStyle;
};

const DEFAULT_BAR_COUNT = 30;
const DEFAULT_HEIGHT = 32;
const MIN_BAR_HEIGHT = 4;
const MAX_LEVEL = 1;
const BAR_WIDTH = 2;
const IDLE_WAVE_STRENGTH = 0.18;
const MIN_METERING_DB = -60;
const MAX_METERING_DB = 0;

const clampLevel = (level: number) => {
"worklet";
return Math.min(Math.max(level, 0), MAX_LEVEL);
};

const normalizeMicLevel = (metering: number | undefined) => {
"worklet";
if (metering === undefined) {
return 0;
}

const clampedMetering = Math.min(Math.max(metering, MIN_METERING_DB), MAX_METERING_DB);
return (clampedMetering - MIN_METERING_DB) / (MAX_METERING_DB - MIN_METERING_DB);
};

const VoiceLevelBar = ({
index,
count,
height,
level,
motionPhase,
color,
}: {
index: number;
count: number;
height: number;
level: SharedValue<number>;
motionPhase: SharedValue<number>;
color: string;
}) => {
const center = (count - 1) / 2;
const distanceFromCenter = Math.abs(index - center);
const emphasis = 1 - distanceFromCenter / Math.max(center, 1);
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why max 1

const phaseOffset = ((index * 1.73) % Math.PI) + index * 0.31;
const driftSpeed = 0.85 + (index % 5) * 0.18;
const driftAmount = 0.12 + (index % 4) * 0.035;

const animatedStyle = useAnimatedStyle(() => {
const boostedLevel = Math.pow(clampLevel(level.value), 0.65);
const drift = (Math.sin(motionPhase.value * driftSpeed + phaseOffset) + 1) / 2;
const idleWave = drift * IDLE_WAVE_STRENGTH;
const randomizedLevel = clampLevel(
idleWave + boostedLevel * (0.78 + emphasis * 0.22) + drift * driftAmount * boostedLevel,
);
const voiceHeight = interpolate(
randomizedLevel,
[0, 1],
[MIN_BAR_HEIGHT + emphasis * 2, height],
);

return {
height: voiceHeight,
opacity: 1,
backgroundColor: color,
};
});

return (
<Animated.View
style={[
{
width: BAR_WIDTH,
borderRadius: 999,
},
animatedStyle,
]}
/>
);
};

const VoiceLevelAnimation = ({
level,
bars = DEFAULT_BAR_COUNT,
color = "#FFFFFF",
height = DEFAULT_HEIGHT,
width = "100%",
style,
}: VoiceLevelAnimationProps) => {
const animatedLevel = useSharedValue(clampLevel(normalizeMicLevel(level)));
const motionPhase = useSharedValue(0);
const barCount = Math.max(3, bars);
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why max 3


useEffect(() => {
animatedLevel.value = withTiming(clampLevel(normalizeMicLevel(level)), {
duration: 35,
easing: Easing.out(Easing.cubic),
});
}, [animatedLevel, level]);

useEffect(() => {
motionPhase.value = withRepeat(
withTiming(Math.PI * 2, {
duration: 520,
easing: Easing.linear,
}),
-1,
false,
);
}, [motionPhase]);

return (
<View
pointerEvents="none"
style={[
{
width,
height,
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
columnGap: 4,
},
style,
]}
>
{Array.from({ length: barCount }, (_, index) => (
<VoiceLevelBar
key={index}
index={index}
count={barCount}
height={height}
level={animatedLevel}
motionPhase={motionPhase}
color={color}
/>
))}
</View>
);
};

export default VoiceLevelAnimation;
1 change: 0 additions & 1 deletion blotztask-mobile/src/shared/constants/assets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ import EditIcon from "../../../assets/images-svg/edit-icon.svg";
export const LOTTIE_ANIMATIONS = {
emptyBox: require("../../../assets/animations/empty-box.json"),
spinner: require("../../../assets/animations/spinner.json"),
voiceWave: require("../../../assets/animations/voice-wave.json"),
} as const;

// Images
Expand Down
Loading