chore: expo audio#1660
Conversation
|
Warning Review the following alerts detected in dependencies. According to your organization's Security Policy, it is recommended to resolve "Warn" alerts. Learn more about Socket for GitHub.
|
0e1943a to
9c16bb7
Compare
…po 53. Other changes as needed.
ErikSin
left a comment
There was a problem hiding this comment.
This is a good first pass, but I think the advantage of the new API is that it simplifies state management and we have not taken full advantage of that here.
The main thing is that we do not need to manually set states related to the recording/audio file (aka timing, whether its playing, whether its recording etc). That is now all exposed in the expo hooks. Both of the hooks in the comapeo code base are now just state syncing and creating states that are already available via the expo hooks.
Let me know if you want to cowork on this together!
| const [status, setStatus] = useState<Audio.RecordingStatus | null>(null); | ||
| const recorder = useAudioRecorder(RECORDING_OPTIONS); | ||
| const status = useAudioRecorderState(recorder, 100); | ||
| const [isRecording, setIsRecording] = useState(false); |
There was a problem hiding this comment.
could we just use status instead of [isRecording, setIsRecording]? It seems like we are manually setting a state here, but that state is already accessible via the status
| const [isPlaying, setPlaying] = useState(false); | ||
| const [duration, setDuration] = useState<number>(0); | ||
| const [currentPosition, setCurrentPosition] = useState(0); | ||
| const lastPositionRef = useRef(0); |
There was a problem hiding this comment.
I don't think we need [isPlaying, setPlaying], [currentPosition, setCurrentPosition], or lastPositionRef.
The new Api has all of these properties exposed in the status, and I believe those are all stateful (because they are exposed via a hook)
There was a problem hiding this comment.
Im actually wondering if we even need this hook anymore. Everything is now exposed via the 2 hooks, and if we get rid of all these other useStates, and useRefs, were left with a useEffect at the top level (that is also no longer needed since we don't need to sync any states), and a start and stop function, which essentially just hit play and stop...
There was a problem hiding this comment.
@cimigree If you think it is better to keep it as a hook, Im open to it. I was suggesting it with the minimal context of doing the code review. In otherwords, I trust your judgment on this and my opinion is never the final say.
There was a problem hiding this comment.
Ok I reverted back to a hook.
| React.useEffect(() => { | ||
| getRecordingPermissionsAsync().then(setAudioPermission); | ||
| }, []); | ||
|
|
There was a problem hiding this comment.
we should do this check before navigating here, and then if there is no permission navigate to the permission screen, otherwise navigate here
There was a problem hiding this comment.
It actually works exactly the same without the permission in there. It already is a Nav prop on the bottom sheet so I didn't think it was necessary as everything worked just fine without adding another nav prop.
0d48e32 to
40ef024
Compare
ErikSin
left a comment
There was a problem hiding this comment.
Good job on the second pass, the code feel much cleaner now!
The main blocking comment is that your hooks are not agnostic and rely on the navigation context. I think both the hooks should not be dealing with navigating to the error bottom sheet and should be dealt with by the consuming screen.
I also noticed alot of checks for the existence of things. Your doing early throws and ternaries for objects that are never undefined/null (according to the type definitions), so i dont think they are necessary. I could be wrong, so let me know if there is some context that I am missing.
Lastly, the nature of the hooks have changed. They are both just forwarding other hooks, which is not a problem, and it does make the code simpler since they are being reused. BUT if we are doing that, I think we need to memoize the values that are being exposed OR expose the entire value. It is just misleading to expose only certain parts of the status when any update to the status is going to cause re-renders regardless.
| if (!recorder) { | ||
| navigate('ErrorBottomSheet'); | ||
| return; | ||
| } |
There was a problem hiding this comment.
I don't think this check is necessary. If useAudioRecorder is not returning the recorder we have a bigger issue with expo...
| stat => setStatus(stat), | ||
| ); | ||
| setRecordingInstance(recording); | ||
| await recorder.prepareToRecordAsync(); |
There was a problem hiding this comment.
this second try that re-attempts to prepare the recording I think is also unnecessary. If its failing once, trying again right after shouldn't make a difference and i think it just complicates the code
| if (!recorder || status.isRecording === false) { | ||
| navigate('ErrorBottomSheet'); | ||
| return; | ||
| } |
There was a problem hiding this comment.
Is there a worry that the recorder won't be instantiated? I think the check for a recorder and then throwing an error is not necessary, it feels like its just complicating the code.
I also don't think we need to check status.isRecording === false. if we just call recorder.stop(); its already in a try catch. So it should either do nothing, or if does throw an error, the catch will cause the error bottom sheet to open, so no point it checking it twice.
There was a problem hiding this comment.
I don't think the hook should be responsible for navigation to the error bottom sheet. It should be agnostic, and only deal with recorder states. And then the screen, which has access to the navigation should be catching the errors and should be in charge of those navigations. IT also will make the stack trace clearer and we will know what screen is causing the error
| const startPlayback = useCallback(async () => { | ||
| if (!recordedSoundRef.current || isPlaying) return; | ||
| if (!player || status.playing) return; |
There was a problem hiding this comment.
i don't think we need to check for the existence of the player here
|
|
||
| const stopPlayback = useCallback(async () => { | ||
| if (!recordedSoundRef.current || !isPlaying) return; | ||
| if (!player || !status.playing) return; |
There was a problem hiding this comment.
same comment as above, i don't think we need check for the existence of the player
| duration: status.duration ? status.duration * 1000 : 0, | ||
| isPlaying: status.playing, | ||
| currentPosition: status.currentTime ? status.currentTime * 1000 : 0, |
There was a problem hiding this comment.
the ternarary checks I don't think are necessary, duration and currentTime are never undefined or null.
Also, these are not memoized so any component consuming these hooks will be rerendered if the status is updated in any way, even if that status update does not update duration, playing, or current time. Which is not necessarily a problem (its a design flaw of react, not us), but it makes me question whether we need to only expose just these 3 things. It we return the entire status, its really clear that the component is consuming the entire status and we are being explicit that renders are effected by the status as a whole.
I'm open to alternatives if you feel strongly about, but IMO it just feels simpler and easier to expose the entire status object.
There was a problem hiding this comment.
the useCallbacks are now unnecessary. Since the status is at the top level, the consuming components will be re-render called any time the status is updated. So memoizing these functions will not stop an unnecessary re-renders because it is going to happen anyways
Secondly, same comment about the error bottom sheet. I dont think the hook should be responsible for the navigation, the consuming component should be responsible for it.
| player, | ||
| status, | ||
| duration: status.duration * 1000, | ||
| isPlaying: status.playing, | ||
| currentPosition: status.currentTime * 1000, | ||
| startPlayback, |
There was a problem hiding this comment.
I think there was a misunderstanding in my comment. I was trying to say It is misleading that the duration, isPlaying, and currentPosition are there own return items. As a hook, it leads me to believe that if I use any of these 3 things, that they will statefully update the consuming component only when those 3 things change.
So i am suggesting that we should get rid of those 3 completely and ONLY return status. That way there is no ambiguity that consuming component will update with EVERY change in status.
Also if you want to return the player, then i don't think we need to expose startPayback() and stopPlayback(). But if we do that, I don't think we should be using this hook at all...
ErikSin
left a comment
There was a problem hiding this comment.
Everything looks pretty good now, but i am getting an error thrown when trying to set permissions.
I wanted to check in about the removal of the useAudioPlayback hook. I'm a little confused because when I originally suggested removing it, you mentioned wanting to keep it. Later in review, I proposed that if we were going to expose the recorder, keeping the hook might not make sense—and I was hoping we could discuss the trade-offs together before deciding.
I realize my suggestion may have come across as a recommendation rather than an invitation to talk it through. My intent was to open a conversation so we could align on the architecture direction, especially since we had different initial takes. I'd love to find a smoother way for us to navigate these kinds of decisions when we have differing views—something I know I have talked to you about before.
Do you have suggestion of how we can make these architectural conversations more collaborative going forward?
| @@ -40,7 +39,6 @@ export function AudioRecording({ | |||
| const {startRecording, stopRecording, status} = useAudioRecording(); | |||
| const timeElapsed = status?.durationMillis || 0; | |||
There was a problem hiding this comment.
status is no longer optional, so can change to const timeElapsed = status.durationMillis
| } | ||
| } | ||
|
|
||
| try { |
There was a problem hiding this comment.
is 2 try/catches necessary here?
| player.play(); | ||
| } | ||
| } catch { | ||
| navigation.navigate('ErrorBottomSheet'); |
There was a problem hiding this comment.
We should pass the error to sentry as well
| console.error('Failed to start recording:', error); | ||
| navigation.replace('ErrorBottomSheet'); |
There was a problem hiding this comment.
we can get rid of the console, and pass the error to sentry
There was a problem hiding this comment.
Something is happening thats causing an error to be thrown. When i grant my permission from this page, its attempting to navigate to the the audio record page and then an error is being thrown. My guess is that the permission is being set but the app hasn't registered that is set yet. The error that is getting thrown is:
ERROR Failed to start recording: [Error: Call to function 'AudioRecorder.record' has been rejected. → Caused by: The 1st argument cannot be cast to type expo.modules.audio.AudioRecorder (received class java.lang.Integer) → Caused by: Cannot use shared object that was already released]
There was a problem hiding this comment.
These changes were the only way I could get this to work. The double try... I think that is why that was there. I also had to take the error handling out of the screen in this situation. If I did two tries, I think I could leave the error handling in the screen, but I would need to have a catch in the hook, which I know you didn't want.
I know it's not what you had in mind. I tried a lot of things. A lot of things.
If you have other ideas, would you mind just taking over the PR?
There was a problem hiding this comment.
Hmm, I am not sure when I said that I didn't want a catch in a hook. Is there a comment that your specifically referring to, or was it something i said in a meeting? Regardless, I think that was a misunderstanding, because I think it is perfectly normal to have a catch in a hook.
Also, the screen should handle navigation to error bottom, but the hook can handle the actual error and just pass it to the screen. Which it was what your doing right now, no? I think I don't fully understand what you mean when you say "I also had to take the error handling out of the screen in this situation".
There was a problem hiding this comment.
Ok I am sure I misunderstood. I think what you didn't want was navigation. My mistake.
The issue here is that with the startRecording function in src/frontend/screens/Audio/AudioRecording/useAudioRecording.ts, there is always an error with enabling permissions. The recorder just isn't ready as soon as we arrive from granting permissions. The second try fixed the issue in the old code. In this pass of this code, I changed it to use a specific if/ then statement. But either way (two tries or the if/ then) an error comes from the hook. And when an error comes from the hook, if I have a try/ catch in that first useEffect in the AudioRecording/index.tsx file, then an error will be thrown and we will navigate to the error bottom sheet.
If I don't have a try catch in there, then it all works.
Is it ok to not have a try catch in that useEffect at the top of src/frontend/screens/Audio/AudioRecording/index.tsx?
There was a problem hiding this comment.
So i updated expo-audio to the latest and it fixed all these problems. no need for a 2 try/catches. And now we can put the try catch back into the useEffect at the top of src/frontend/screens/Audio/AudioRecording/index.tsx
There was a problem hiding this comment.
@ErikSin What is the latest? I tried 1.1.1 and I got the same thing when I removed the extra if/ then.
screen-20260205-234826.mp4
There was a problem hiding this comment.
yup 1.1.1. Works on both my emulator and physical device
Screen.Recording.2026-02-05.at.3.26.14.PM.mov
in the hook:
const startRecording = useCallback(async () => {
await recorder.prepareToRecordAsync();
recorder.record();
}, [recorder]);in the screen:
React.useEffect(() => {
const start = async () => {
await startRecording();
setIsLoading(false);
};
try {
start();
} catch {
navigation.navigate('ErrorBottomSheet');
}
}, [startRecording, navigation]);There was a problem hiding this comment.
Ok thanks. I had the try catch wrong. This worked and is now pushed up.
ErikSin
left a comment
There was a problem hiding this comment.
i think everything is addressed now, thanks!
| start(); | ||
| } catch { | ||
| navigation.navigate('ErrorBottomSheet'); | ||
| } |
There was a problem hiding this comment.
Sorry, my example that i gave was just simplified to show that it was working. We should still catch the error and pass it to sentry
We should also do a navigation.replace() here so the user is navigated back to the observation create screen when they close the error bottom sheet.
Migration from expo-av to expo-audio
stacked on #1655
stacked on #1651
It doesn't really have to be stacked -- just thought it made sense to deal with this after the expo upgrade.
Closes #1652
Some of the differences, aside from hook and method names:
Also, to make the React Compiler happy, I had to refactor the try/ catch blocks: