Skip to content

Commit 80e8e1e

Browse files
committed
feat: volume change event support
1 parent 42f52a8 commit 80e8e1e

14 files changed

+419
-24
lines changed

android/src/main/java/expo/modules/speechrecognition/ExpoSpeechRecognitionModule.kt

+20-12
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,8 @@ class ExpoSpeechRecognitionModule : Module() {
8686
"start",
8787
// Called when there's results (as a string array, not API compliant)
8888
"results",
89+
// Fired when the input volume changes
90+
"volumechange",
8991
)
9092

9193
Function("getDefaultRecognitionService") {
@@ -325,26 +327,32 @@ class ExpoSpeechRecognitionModule : Module() {
325327
promise: Promise,
326328
) {
327329
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
328-
promise.resolve(mapOf(
329-
"locales" to mutableListOf<String>(),
330-
"installedLocales" to mutableListOf<String>(),
331-
))
330+
promise.resolve(
331+
mapOf(
332+
"locales" to mutableListOf<String>(),
333+
"installedLocales" to mutableListOf<String>(),
334+
),
335+
)
332336
return
333337
}
334338

335339
if (options.androidRecognitionServicePackage == null && !SpeechRecognizer.isOnDeviceRecognitionAvailable(appContext)) {
336-
promise.resolve(mapOf(
337-
"locales" to mutableListOf<String>(),
338-
"installedLocales" to mutableListOf<String>(),
339-
))
340+
promise.resolve(
341+
mapOf(
342+
"locales" to mutableListOf<String>(),
343+
"installedLocales" to mutableListOf<String>(),
344+
),
345+
)
340346
return
341347
}
342348

343349
if (options.androidRecognitionServicePackage != null && !SpeechRecognizer.isRecognitionAvailable(appContext)) {
344-
promise.resolve(mapOf(
345-
"locales" to mutableListOf<String>(),
346-
"installedLocales" to mutableListOf<String>(),
347-
))
350+
promise.resolve(
351+
mapOf(
352+
"locales" to mutableListOf<String>(),
353+
"installedLocales" to mutableListOf<String>(),
354+
),
355+
)
348356
return
349357
}
350358

android/src/main/java/expo/modules/speechrecognition/ExpoSpeechRecognitionOptions.kt

+11
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,17 @@ class SpeechRecognitionOptions : Record {
5050

5151
@Field
5252
val iosCategory: Map<String, Any>? = null
53+
54+
@Field
55+
val volumeChangeEventOptions: VolumeChangeEventOptions? = null
56+
}
57+
58+
class VolumeChangeEventOptions : Record {
59+
@Field
60+
val enabled: Boolean? = false
61+
62+
@Field
63+
val intervalMillis: Int? = null
5364
}
5465

5566
class RecordingOptions : Record {

android/src/main/java/expo/modules/speechrecognition/ExpoSpeechService.kt

+20
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,9 @@ class ExpoSpeechService(
5050
private var speech: SpeechRecognizer? = null
5151
private val mainHandler = Handler(Looper.getMainLooper())
5252

53+
private lateinit var options: SpeechRecognitionOptions
54+
private var lastVolumeChangeEventTime: Long = 0L
55+
5356
/** Audio recorder for persisting audio */
5457
private var audioRecorder: ExpoAudioRecorder? = null
5558

@@ -108,6 +111,7 @@ class ExpoSpeechService(
108111

109112
/** Starts speech recognition */
110113
fun start(options: SpeechRecognitionOptions) {
114+
this.options = options
111115
mainHandler.post {
112116
log("Start recognition.")
113117

@@ -119,6 +123,7 @@ class ExpoSpeechService(
119123
delayedFileStreamer = null
120124
recognitionState = RecognitionState.STARTING
121125
soundState = SoundState.INACTIVE
126+
lastVolumeChangeEventTime = 0L
122127
try {
123128
val intent = createSpeechIntent(options)
124129
speech = createSpeechRecognizer(options)
@@ -454,6 +459,21 @@ class ExpoSpeechService(
454459
}
455460

456461
override fun onRmsChanged(rmsdB: Float) {
462+
if (options.volumeChangeEventOptions?.enabled != true) {
463+
return
464+
}
465+
466+
val intervalMs = options.volumeChangeEventOptions?.intervalMillis
467+
468+
if (intervalMs == null) {
469+
sendEvent("volumechange", mapOf("rmsdB" to rmsdB))
470+
} else {
471+
val currentTime = System.currentTimeMillis()
472+
if (currentTime - lastVolumeChangeEventTime >= intervalMs) {
473+
sendEvent("volumechange", mapOf("rmsdB" to rmsdB))
474+
lastVolumeChangeEventTime = currentTime
475+
}
476+
}
457477
/*
458478
val isSilent = rmsdB <= 0
459479

example/App.tsx

+21-1
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ import {
4747
AndroidOutputFormat,
4848
IOSOutputFormat,
4949
} from "expo-av/build/Audio";
50+
import { VolumeMeteringAvatar } from "./components/VolumeMeteringAvatar";
5051

5152
const speechRecognitionServices = getSpeechRecognitionServices();
5253

@@ -72,6 +73,10 @@ export default function App() {
7273
requiresOnDeviceRecognition: false,
7374
addsPunctuation: true,
7475
contextualStrings: ["Carlsen", "Ian Nepomniachtchi", "Praggnanandhaa"],
76+
volumeChangeEventOptions: {
77+
enabled: false,
78+
intervalMillis: 300,
79+
},
7580
});
7681

7782
useSpeechRecognitionEvent("result", (ev) => {
@@ -140,6 +145,10 @@ export default function App() {
140145
<SafeAreaView style={styles.container}>
141146
<StatusBar style="dark" translucent={false} />
142147

148+
{settings.volumeChangeEventOptions?.enabled ? (
149+
<VolumeMeteringAvatar />
150+
) : null}
151+
143152
<View style={styles.card}>
144153
<Text style={styles.text}>
145154
{error ? JSON.stringify(error) : "Error messages go here"}
@@ -510,6 +519,17 @@ function GeneralSettings(props: {
510519
checked={Boolean(settings.continuous)}
511520
onPress={() => handleChange("continuous", !settings.continuous)}
512521
/>
522+
523+
<CheckboxButton
524+
title="Volume events"
525+
checked={Boolean(settings.volumeChangeEventOptions?.enabled)}
526+
onPress={() =>
527+
handleChange("volumeChangeEventOptions", {
528+
enabled: !settings.volumeChangeEventOptions?.enabled,
529+
intervalMillis: settings.volumeChangeEventOptions?.intervalMillis,
530+
})
531+
}
532+
/>
513533
</View>
514534

515535
<View style={styles.textOptionContainer}>
@@ -714,7 +734,7 @@ function AndroidSettings(props: {
714734
onPress={() =>
715735
handleChange("androidIntentOptions", {
716736
...settings.androidIntentOptions,
717-
[key]: !settings.androidIntentOptions?.[key] ?? false,
737+
[key]: !settings.androidIntentOptions?.[key],
718738
})
719739
}
720740
/>

example/assets/avatar.png

18.8 KB
Loading

example/babel.config.js

+1
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ module.exports = function (api) {
44
return {
55
presets: ["babel-preset-expo"],
66
plugins: [
7+
"react-native-reanimated/plugin",
78
[
89
"module-resolver",
910
{
+140
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
import { useSpeechRecognitionEvent } from "expo-speech-recognition";
2+
import { Image, StyleSheet, View } from "react-native";
3+
import Animated, {
4+
Extrapolation,
5+
interpolate,
6+
useAnimatedStyle,
7+
useSharedValue,
8+
withTiming,
9+
Easing,
10+
withSpring,
11+
withDelay,
12+
withSequence,
13+
} from "react-native-reanimated";
14+
const avatar = require("../assets/avatar.png");
15+
16+
const minScale = 1;
17+
const maxScale = 1.5;
18+
19+
/**
20+
* This is an example component that uses the `volumechange` event to animate the volume metering of a user's voice.
21+
*/
22+
export function VolumeMeteringAvatar() {
23+
const haloScale = useSharedValue(minScale);
24+
const pulseScale = useSharedValue(minScale);
25+
const pulseOpacity = useSharedValue(0);
26+
27+
const haloAnimatedStyle = useAnimatedStyle(() => ({
28+
transform: [{ scale: haloScale.value }],
29+
}));
30+
31+
const pulseAnimatedStyle = useAnimatedStyle(() => ({
32+
opacity: pulseOpacity.value,
33+
transform: [{ scale: pulseScale.value }],
34+
}));
35+
36+
const reset = () => {
37+
haloScale.value = minScale;
38+
pulseScale.value = minScale;
39+
pulseOpacity.value = 0;
40+
};
41+
42+
useSpeechRecognitionEvent("start", reset);
43+
useSpeechRecognitionEvent("end", reset);
44+
45+
useSpeechRecognitionEvent("volumechange", (event) => {
46+
// Don't animate anything if the volume is too low
47+
if (event.rmsdB <= 1) {
48+
return;
49+
}
50+
const newScale = interpolate(
51+
event.rmsdB,
52+
[-2, 10], // The rmsDB range is between -2 and 10
53+
[minScale, maxScale],
54+
Extrapolation.CLAMP,
55+
);
56+
57+
// Animate the halo scaling
58+
haloScale.value = withSequence(
59+
withSpring(newScale, {
60+
damping: 15,
61+
stiffness: 150,
62+
}),
63+
withTiming(minScale, {
64+
duration: 1000,
65+
easing: Easing.linear,
66+
}),
67+
);
68+
69+
// Animate the pulse (scale and fade out)
70+
if (pulseScale.value < newScale) {
71+
pulseScale.value = withSequence(
72+
withTiming(maxScale, {
73+
duration: 1000,
74+
easing: Easing.out(Easing.quad),
75+
}),
76+
withTiming(minScale, {
77+
duration: 400,
78+
easing: Easing.linear,
79+
}),
80+
);
81+
pulseOpacity.value = withSequence(
82+
withTiming(1, { duration: 800 }),
83+
withDelay(300, withTiming(0, { duration: 200 })),
84+
);
85+
}
86+
});
87+
88+
return (
89+
<View style={styles.container}>
90+
<View style={styles.pulseContainer}>
91+
<Animated.View style={[styles.pulse, pulseAnimatedStyle]} />
92+
</View>
93+
<View style={styles.pulseContainer}>
94+
<Animated.View style={[styles.halo, haloAnimatedStyle]} />
95+
</View>
96+
<View style={[styles.centered]}>
97+
<Image source={avatar} style={styles.avatar} />
98+
</View>
99+
</View>
100+
);
101+
}
102+
103+
const styles = StyleSheet.create({
104+
container: {
105+
position: "relative",
106+
marginVertical: 20,
107+
},
108+
pulseContainer: {
109+
position: "absolute",
110+
top: 0,
111+
bottom: 0,
112+
left: 0,
113+
right: 0,
114+
justifyContent: "center",
115+
alignItems: "center",
116+
},
117+
pulse: {
118+
borderWidth: 1,
119+
borderColor: "#539bf5",
120+
width: 96,
121+
height: 96,
122+
borderRadius: 96,
123+
},
124+
halo: {
125+
backgroundColor: "#6b7280",
126+
width: 96,
127+
height: 96,
128+
borderRadius: 96,
129+
},
130+
centered: {
131+
justifyContent: "center",
132+
alignItems: "center",
133+
},
134+
avatar: {
135+
width: 96,
136+
height: 96,
137+
borderRadius: 96,
138+
overflow: "hidden",
139+
},
140+
});

example/ios/Podfile.lock

+27-2
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ PODS:
3939
- ReactCommon/turbomodule/bridging
4040
- ReactCommon/turbomodule/core
4141
- Yoga
42-
- ExpoSpeechRecognition (0.2.17):
42+
- ExpoSpeechRecognition (0.2.21):
4343
- ExpoModulesCore
4444
- EXSplashScreen (0.27.5):
4545
- DoubleConversion
@@ -1226,6 +1226,27 @@ PODS:
12261226
- React-logger (= 0.74.5)
12271227
- React-perflogger (= 0.74.5)
12281228
- React-utils (= 0.74.5)
1229+
- RNReanimated (3.10.1):
1230+
- DoubleConversion
1231+
- glog
1232+
- hermes-engine
1233+
- RCT-Folly (= 2024.01.01.00)
1234+
- RCTRequired
1235+
- RCTTypeSafety
1236+
- React-Codegen
1237+
- React-Core
1238+
- React-debug
1239+
- React-Fabric
1240+
- React-featureflags
1241+
- React-graphics
1242+
- React-ImageManager
1243+
- React-NativeModulesApple
1244+
- React-RCTFabric
1245+
- React-rendererdebug
1246+
- React-utils
1247+
- ReactCommon/turbomodule/bridging
1248+
- ReactCommon/turbomodule/core
1249+
- Yoga
12291250
- SocketRocket (0.7.0)
12301251
- Yoga (0.0.0)
12311252

@@ -1295,6 +1316,7 @@ DEPENDENCIES:
12951316
- React-runtimescheduler (from `../node_modules/react-native/ReactCommon/react/renderer/runtimescheduler`)
12961317
- React-utils (from `../node_modules/react-native/ReactCommon/react/utils`)
12971318
- ReactCommon/turbomodule/core (from `../node_modules/react-native/ReactCommon`)
1319+
- RNReanimated (from `../node_modules/react-native-reanimated`)
12981320
- Yoga (from `../node_modules/react-native/ReactCommon/yoga`)
12991321

13001322
SPEC REPOS:
@@ -1429,6 +1451,8 @@ EXTERNAL SOURCES:
14291451
:path: "../node_modules/react-native/ReactCommon/react/utils"
14301452
ReactCommon:
14311453
:path: "../node_modules/react-native/ReactCommon"
1454+
RNReanimated:
1455+
:path: "../node_modules/react-native-reanimated"
14321456
Yoga:
14331457
:path: "../node_modules/react-native/ReactCommon/yoga"
14341458

@@ -1443,7 +1467,7 @@ SPEC CHECKSUMS:
14431467
ExpoFont: 00756e6c796d8f7ee8d211e29c8b619e75cbf238
14441468
ExpoKeepAwake: 3b8815d9dd1d419ee474df004021c69fdd316d08
14451469
ExpoModulesCore: a113755f96c40590671f01cfcdce8ebdf0cf5f83
1446-
ExpoSpeechRecognition: 66f2525786fd2fe299eb001e84b0176fd9c4252b
1470+
ExpoSpeechRecognition: 80eefd4f4bd7541f5fad24744b19a4b36a365414
14471471
EXSplashScreen: a7e8d13c476f9937e39d654af4235758b567a1be
14481472
FBLazyVector: ac12dc084d1c8ec4cc4d7b3cf1b0ebda6dab85af
14491473
fmt: 4c2741a687cc09f0634a2e2c72a838b99f1ff120
@@ -1496,6 +1520,7 @@ SPEC CHECKSUMS:
14961520
React-runtimescheduler: cfbe85c3510c541ec6dc815c7729b41304b67961
14971521
React-utils: f242eb7e7889419d979ca0e1c02ccc0ea6e43b29
14981522
ReactCommon: f7da14a8827b72704169a48c929bcde802698361
1523+
RNReanimated: 58a768c2c17a5589ef732fa6bd8d7ed0eb6df1c1
14991524
SocketRocket: abac6f5de4d4d62d24e11868d7a2f427e0ef940d
15001525
Yoga: 2246eea72aaf1b816a68a35e6e4b74563653ae09
15011526

0 commit comments

Comments
 (0)