Apk compilation
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { Audio, InterruptionModeAndroid, InterruptionModeIOS } from "expo-av";
|
||||
import Voice from "@react-native-voice/voice";
|
||||
import { router, useFocusEffect } from "expo-router";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import Svg, { Path } from "react-native-svg";
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
KeyboardAvoidingView,
|
||||
NativeModules,
|
||||
Platform,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
@@ -26,41 +27,22 @@ function formatDuration(durationMs: number) {
|
||||
.padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function buildMimeType(uri: string) {
|
||||
const extension = uri.split(".").pop()?.split("?")[0]?.toLowerCase();
|
||||
|
||||
switch (extension) {
|
||||
case "wav":
|
||||
return "audio/wav";
|
||||
case "caf":
|
||||
return "audio/x-caf";
|
||||
case "webm":
|
||||
return "audio/webm";
|
||||
case "mp3":
|
||||
return "audio/mpeg";
|
||||
default:
|
||||
return "audio/m4a";
|
||||
}
|
||||
}
|
||||
|
||||
function buildFileExtension(uri: string) {
|
||||
return uri.split(".").pop()?.split("?")[0]?.toLowerCase() || "m4a";
|
||||
}
|
||||
|
||||
export default function RecorderScreen() {
|
||||
const [backendUrl, setBackendUrl] = useState("");
|
||||
const [authToken, setAuthToken] = useState("");
|
||||
const [fieldName, setFieldName] = useState("file");
|
||||
const [locale, setLocale] = useState<Locale>("ca");
|
||||
const [strings, setStrings] = useState(() => getStrings("ca"));
|
||||
const [recording, setRecording] = useState<Audio.Recording | null>(null);
|
||||
const [recordingUri, setRecordingUri] = useState<string | null>(null);
|
||||
const [recordingMs, setRecordingMs] = useState(0);
|
||||
|
||||
const [transcript, setTranscript] = useState("");
|
||||
const [interimTranscript, setInterimTranscript] = useState("");
|
||||
const [statusMessage, setStatusMessage] = useState("");
|
||||
const [responsePreview, setResponsePreview] = useState("");
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [isSending, setIsSending] = useState(false);
|
||||
const [isHolding, setIsHolding] = useState(false);
|
||||
const recordingRef = useRef<Audio.Recording | null>(null);
|
||||
const [listeningMs, setListeningMs] = useState(0);
|
||||
const startRef = useRef<number>(0);
|
||||
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const listeningActiveRef = useRef(false);
|
||||
|
||||
const refreshSettings = useCallback(() => {
|
||||
let isMounted = true;
|
||||
@@ -75,7 +57,6 @@ export default function RecorderScreen() {
|
||||
|
||||
setBackendUrl(settings.backendUrl);
|
||||
setAuthToken(settings.authToken);
|
||||
setFieldName(settings.fieldName);
|
||||
setLocale(settings.language);
|
||||
setStrings(getStrings(settings.language));
|
||||
} catch {
|
||||
@@ -95,222 +76,187 @@ export default function RecorderScreen() {
|
||||
useFocusEffect(refreshSettings);
|
||||
|
||||
useEffect(() => {
|
||||
if (!recording) {
|
||||
if (Platform.OS === "web" || !NativeModules.Voice) {
|
||||
return;
|
||||
}
|
||||
|
||||
const interval = setInterval(() => {
|
||||
void recording.getStatusAsync().then((status) => {
|
||||
if (typeof status.durationMillis === "number") {
|
||||
setRecordingMs(status.durationMillis ?? 0);
|
||||
}
|
||||
});
|
||||
}, 250);
|
||||
Voice.onSpeechStart = (e) => {
|
||||
console.log("Voice.onSpeechStart", e);
|
||||
setTranscript("");
|
||||
setInterimTranscript("");
|
||||
setStatusMessage(strings.recording);
|
||||
startRef.current = Date.now();
|
||||
timerRef.current = setInterval(() => {
|
||||
const elapsed = Date.now() - startRef.current;
|
||||
setListeningMs(elapsed);
|
||||
}, 250);
|
||||
};
|
||||
|
||||
Voice.onSpeechEnd = (e) => {
|
||||
console.log("Voice.onSpeechEnd", e);
|
||||
listeningActiveRef.current = false;
|
||||
if (timerRef.current) {
|
||||
clearInterval(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
Voice.onSpeechResults = (e) => {
|
||||
console.log("Voice.onSpeechResults", e);
|
||||
const values = (e as unknown as { value: string[] }).value;
|
||||
if (values && values.length > 0) {
|
||||
const last = values[values.length - 1];
|
||||
setTranscript(last);
|
||||
}
|
||||
};
|
||||
|
||||
Voice.onSpeechPartialResults = (e) => {
|
||||
console.log("Voice.onSpeechPartialResults", e);
|
||||
const values = (e as unknown as { value: string[] }).value;
|
||||
if (values && values.length > 0) {
|
||||
const last = values[values.length - 1];
|
||||
setInterimTranscript(last || "");
|
||||
}
|
||||
};
|
||||
|
||||
Voice.onSpeechError = (e) => {
|
||||
console.log("Voice.onSpeechError", e);
|
||||
const message = (e as unknown as { error?: { message: string } }).error?.message || "Speech recognition error";
|
||||
setStatusMessage(message);
|
||||
if (timerRef.current) {
|
||||
clearInterval(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
setListeningMs(0);
|
||||
listeningActiveRef.current = false;
|
||||
Alert.alert(strings.recordingFailedTitle, message);
|
||||
};
|
||||
|
||||
return () => {
|
||||
clearInterval(interval);
|
||||
Voice.removeAllListeners();
|
||||
if (timerRef.current) {
|
||||
clearInterval(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [recording]);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (recording) {
|
||||
void recording.stopAndUnloadAsync().catch(() => undefined);
|
||||
if (listeningActiveRef.current && Platform.OS !== "web" && NativeModules.Voice) {
|
||||
Voice.stop().catch(() => undefined);
|
||||
listeningActiveRef.current = false;
|
||||
}
|
||||
};
|
||||
}, [recording]);
|
||||
}, []);
|
||||
|
||||
async function startRecording() {
|
||||
async function startListening() {
|
||||
try {
|
||||
setResponsePreview("");
|
||||
setRecordingUri(null);
|
||||
|
||||
const permission = await Audio.requestPermissionsAsync();
|
||||
|
||||
if (!permission.granted) {
|
||||
setStatusMessage(strings.micPermissionDenied);
|
||||
Alert.alert(
|
||||
strings.micAccessRequiredTitle,
|
||||
strings.micAccessRequiredMsg,
|
||||
);
|
||||
setTranscript("");
|
||||
setInterimTranscript("");
|
||||
const localeCode =
|
||||
locale.includes("ca")
|
||||
? "ca-ES"
|
||||
: locale.includes("es")
|
||||
? "es-ES"
|
||||
: "en-US";
|
||||
setIsHolding(true);
|
||||
if (Platform.OS === "web") {
|
||||
console.log("Voice not available on web");
|
||||
Alert.alert("Not supported", "Speech recognition is only available on mobile devices. Open the app on Android or iOS.");
|
||||
setIsHolding(false);
|
||||
return;
|
||||
}
|
||||
|
||||
await Audio.setAudioModeAsync({
|
||||
allowsRecordingIOS: true,
|
||||
interruptionModeAndroid: InterruptionModeAndroid.DoNotMix,
|
||||
interruptionModeIOS: InterruptionModeIOS.DoNotMix,
|
||||
playsInSilentModeIOS: true,
|
||||
shouldDuckAndroid: true,
|
||||
staysActiveInBackground: false,
|
||||
});
|
||||
|
||||
const result = await Audio.Recording.createAsync(
|
||||
Audio.RecordingOptionsPresets.HIGH_QUALITY,
|
||||
);
|
||||
|
||||
recordingRef.current = result.recording;
|
||||
setRecording(result.recording);
|
||||
setRecordingMs(0);
|
||||
setStatusMessage(strings.recording);
|
||||
if (!NativeModules.Voice) {
|
||||
Alert.alert("Not supported", "Speech recognition module not found. Make sure the app is built with native modules.");
|
||||
setIsHolding(false);
|
||||
return;
|
||||
}
|
||||
await Voice.start(localeCode);
|
||||
listeningActiveRef.current = true;
|
||||
} catch (error) {
|
||||
setStatusMessage(strings.couldNotStartRecording);
|
||||
Alert.alert(
|
||||
strings.recordingFailedTitle,
|
||||
error instanceof Error ? error.message : "",
|
||||
);
|
||||
const msg = error instanceof Error ? error.message : "Failed to start speech recognition";
|
||||
console.error("Voice.start failed:", error);
|
||||
Alert.alert(strings.recordingFailedTitle, msg);
|
||||
}
|
||||
}
|
||||
|
||||
async function stopRecordingAndUpload() {
|
||||
if (!recordingRef.current) {
|
||||
async function stopListeningAndSend() {
|
||||
setIsHolding(false);
|
||||
const wasListening = listeningActiveRef.current;
|
||||
if (!wasListening) {
|
||||
setStatusMessage(transcript ? strings.voiceMessageSent : strings.readyToRecord);
|
||||
return;
|
||||
}
|
||||
listeningActiveRef.current = false;
|
||||
if (Platform.OS === "web") {
|
||||
const finalText = (transcript + " " + interimTranscript).trim().replace(/\s+/g, " ");
|
||||
if (finalText) {
|
||||
await sendCommand(finalText);
|
||||
} else {
|
||||
setStatusMessage(strings.readyToRecord);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const activeRecording = recordingRef.current;
|
||||
const currentStatus = await activeRecording.getStatusAsync();
|
||||
const durationMillis = currentStatus.durationMillis ?? 0;
|
||||
await Voice.stop();
|
||||
|
||||
await activeRecording.stopAndUnloadAsync();
|
||||
await Audio.setAudioModeAsync({
|
||||
allowsRecordingIOS: false,
|
||||
playsInSilentModeIOS: true,
|
||||
});
|
||||
const finalText = (transcript + " " + interimTranscript).trim().replace(/\s+/g, " ");
|
||||
|
||||
const uri = activeRecording.getURI();
|
||||
recordingRef.current = null;
|
||||
setRecording(null);
|
||||
setRecordingMs(durationMillis);
|
||||
|
||||
if (!uri) {
|
||||
if (!finalText) {
|
||||
setStatusMessage(strings.readyToRecord);
|
||||
return;
|
||||
}
|
||||
|
||||
setRecordingUri(uri);
|
||||
setStatusMessage(strings.finishedUpload);
|
||||
|
||||
const trimmedUrl = backendUrl.trim().replace(/\/+$/, '');
|
||||
const uploadUrl = trimmedUrl.endsWith('/audio/upload')
|
||||
? trimmedUrl
|
||||
: `${trimmedUrl}/audio/upload`;
|
||||
if (uploadUrl) {
|
||||
try {
|
||||
const mimeType = buildMimeType(uri);
|
||||
const extension = buildFileExtension(uri);
|
||||
const formData = new FormData();
|
||||
|
||||
formData.append(fieldName.trim() || "file", {
|
||||
name: `recording-${Date.now()}.${extension}`,
|
||||
type: mimeType,
|
||||
uri: uri,
|
||||
} as never);
|
||||
|
||||
const headers: Record<string, string> = {};
|
||||
if (authToken.trim()) {
|
||||
headers.Authorization = `Bearer ${authToken.trim()}`;
|
||||
}
|
||||
|
||||
const response = await fetch(uploadUrl, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: formData,
|
||||
});
|
||||
|
||||
const responseText = await response.text();
|
||||
setResponsePreview(responseText.slice(0, 400));
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`${response.status}. ${responseText}`);
|
||||
}
|
||||
|
||||
setStatusMessage(strings.voiceMessageSent);
|
||||
} catch (error) {
|
||||
setStatusMessage(strings.uploadFailed);
|
||||
Alert.alert(
|
||||
strings.uploadFailed,
|
||||
error instanceof Error ? error.message : "",
|
||||
);
|
||||
}
|
||||
} else {
|
||||
setStatusMessage(strings.noBackendUrl);
|
||||
}
|
||||
await sendCommand(finalText);
|
||||
} catch (error) {
|
||||
recordingRef.current = null;
|
||||
setRecording(null);
|
||||
setStatusMessage(strings.stopFailedTitle);
|
||||
Alert.alert(
|
||||
strings.stopFailedTitle,
|
||||
error instanceof Error ? error.message : "",
|
||||
);
|
||||
listeningActiveRef.current = false;
|
||||
const msg = error instanceof Error ? error.message : "Stop failed";
|
||||
console.error("Voice.stop failed:", error);
|
||||
setStatusMessage(msg);
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePressIn() {
|
||||
if (isUploading) return;
|
||||
setIsHolding(true);
|
||||
await startRecording();
|
||||
}
|
||||
async function sendCommand(text: string) {
|
||||
const trimmedUrl = backendUrl.trim().replace(/\/+$/, "");
|
||||
const commandUrl = trimmedUrl.endsWith("/commands")
|
||||
? `${trimmedUrl}/text`
|
||||
: `${trimmedUrl}/commands/text`;
|
||||
|
||||
async function handlePressOut() {
|
||||
if (!isHolding) return;
|
||||
setIsHolding(false);
|
||||
await stopRecordingAndUpload();
|
||||
}
|
||||
|
||||
async function uploadRecording(uriOverride?: string) {
|
||||
const targetUri = uriOverride ?? recordingUri;
|
||||
|
||||
if (!targetUri) {
|
||||
return;
|
||||
}
|
||||
|
||||
const trimmedUrl = backendUrl.trim().replace(/\/+$/, '');
|
||||
const uploadUrl = trimmedUrl.endsWith('/audio/upload')
|
||||
? trimmedUrl
|
||||
: `${trimmedUrl}/audio/upload`;
|
||||
|
||||
if (!uploadUrl) {
|
||||
Alert.alert(strings.missingBackendUrlTitle, strings.missingBackendUrlMsg);
|
||||
if (!commandUrl) {
|
||||
setStatusMessage(strings.noBackendUrl);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsUploading(true);
|
||||
setIsSending(true);
|
||||
setStatusMessage(strings.uploadingRecording);
|
||||
setResponsePreview("");
|
||||
|
||||
const mimeType = buildMimeType(targetUri);
|
||||
const extension = buildFileExtension(targetUri);
|
||||
const formData = new FormData();
|
||||
|
||||
formData.append(fieldName.trim() || "file", {
|
||||
name: `recording-${Date.now()}.${extension}`,
|
||||
type: mimeType,
|
||||
uri: targetUri,
|
||||
} as never);
|
||||
|
||||
const headers: Record<string, string> = {};
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
|
||||
if (authToken.trim()) {
|
||||
headers.Authorization = `Bearer ${authToken.trim()}`;
|
||||
}
|
||||
|
||||
const response = await fetch(uploadUrl, {
|
||||
const response = await fetch(commandUrl, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: formData,
|
||||
body: JSON.stringify({ text }),
|
||||
});
|
||||
|
||||
const responseText = await response.text();
|
||||
setResponsePreview(responseText.slice(0, 400));
|
||||
setTranscript("");
|
||||
setInterimTranscript("");
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`${response.status}. ${responseText}`);
|
||||
}
|
||||
|
||||
setStatusMessage(strings.uploadComplete);
|
||||
setStatusMessage(strings.voiceMessageSent);
|
||||
} catch (error) {
|
||||
setStatusMessage(strings.uploadFailed);
|
||||
Alert.alert(
|
||||
@@ -318,17 +264,28 @@ export default function RecorderScreen() {
|
||||
error instanceof Error ? error.message : "",
|
||||
);
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
setIsSending(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePressIn() {
|
||||
if (isSending) return;
|
||||
await startListening();
|
||||
}
|
||||
|
||||
async function handlePressOut() {
|
||||
await stopListeningAndSend();
|
||||
}
|
||||
|
||||
const releaseLabel = t("releaseToStop", locale);
|
||||
const holdLabel = t("holdToRecord", locale);
|
||||
const openSettingsLabel = t("openSettingsHint", locale);
|
||||
const openSettingsHint = t("openSettingsHint", locale);
|
||||
const appTitleLabel = t("appTitle", locale);
|
||||
const recorderTitleLabel = t("recorderTitle", locale);
|
||||
const serverResponseLabel = t("serverResponse", locale);
|
||||
|
||||
const displayText = interimTranscript || transcript;
|
||||
|
||||
return (
|
||||
<View style={styles.safeArea}>
|
||||
<KeyboardAvoidingView
|
||||
@@ -367,20 +324,20 @@ export default function RecorderScreen() {
|
||||
|
||||
<View style={styles.panel}>
|
||||
<Text style={[styles.meterValueCentered, isHolding && { color: "#d04f2d" }]}>
|
||||
{formatDuration(recordingMs)}
|
||||
{formatDuration(listeningMs)}
|
||||
</Text>
|
||||
|
||||
<Pressable
|
||||
disabled={isUploading}
|
||||
disabled={isSending}
|
||||
onPressIn={handlePressIn}
|
||||
onPressOut={handlePressOut}
|
||||
style={[
|
||||
styles.micButton,
|
||||
isHolding ? styles.holdingButton : styles.idleButton,
|
||||
isUploading && styles.buttonDisabled,
|
||||
isSending && styles.buttonDisabled,
|
||||
]}
|
||||
>
|
||||
{isUploading ? (
|
||||
{isSending ? (
|
||||
<ActivityIndicator color="#fff6f3" size="large" />
|
||||
) : (
|
||||
<Svg width="64" height="64" viewBox="0 0 24 24" fill="none">
|
||||
@@ -406,21 +363,23 @@ export default function RecorderScreen() {
|
||||
)}
|
||||
</Pressable>
|
||||
|
||||
<Text style={styles.statusText}>{statusMessage || strings.readyToRecord}</Text>
|
||||
<Text style={styles.statusText}>
|
||||
{statusMessage || strings.readyToRecord}
|
||||
</Text>
|
||||
<Text style={styles.helperText}>
|
||||
{isHolding
|
||||
? releaseLabel
|
||||
: backendUrl.trim()
|
||||
? holdLabel
|
||||
: openSettingsLabel}
|
||||
: openSettingsHint}
|
||||
</Text>
|
||||
|
||||
{responsePreview ? (
|
||||
<View style={styles.responseBox}>
|
||||
<Text style={styles.responseLabel}>{serverResponseLabel}</Text>
|
||||
<Text style={styles.responseText}>{responsePreview}</Text>
|
||||
{displayText && (
|
||||
<View style={styles.transcriptBox}>
|
||||
<Text style={styles.transcriptLabel}>{strings.serverResponse}</Text>
|
||||
<Text style={styles.transcriptText}>{displayText}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
)}
|
||||
</View>
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
@@ -511,14 +470,6 @@ const styles = StyleSheet.create({
|
||||
backgroundColor: "#d04f2d",
|
||||
transform: [{ scale: 1.08 }],
|
||||
},
|
||||
micButtonText: {
|
||||
color: "#fff6f3",
|
||||
fontSize: 20,
|
||||
fontWeight: "800",
|
||||
},
|
||||
recordingLabel: {
|
||||
fontSize: 18,
|
||||
},
|
||||
buttonDisabled: {
|
||||
opacity: 0.45,
|
||||
},
|
||||
@@ -534,6 +485,28 @@ const styles = StyleSheet.create({
|
||||
lineHeight: 18,
|
||||
textAlign: "center",
|
||||
},
|
||||
transcriptBox: {
|
||||
backgroundColor: "#f7f0e0",
|
||||
borderRadius: 16,
|
||||
gap: 6,
|
||||
marginTop: 4,
|
||||
padding: 14,
|
||||
maxWidth: "100%",
|
||||
},
|
||||
transcriptLabel: {
|
||||
color: "#13304a",
|
||||
fontSize: 13,
|
||||
fontWeight: "700",
|
||||
textTransform: "uppercase",
|
||||
textAlign: "center",
|
||||
},
|
||||
transcriptText: {
|
||||
color: "#36475a",
|
||||
fontSize: 16,
|
||||
lineHeight: 22,
|
||||
textAlign: "center",
|
||||
fontWeight: "500",
|
||||
},
|
||||
responseBox: {
|
||||
backgroundColor: "#f7f0e0",
|
||||
borderRadius: 16,
|
||||
|
||||
Reference in New Issue
Block a user