Skip to content

Commit 81dcb77

Browse files
authored
Expo App SDK 53 (#198)
* restyled most components for better visuals * added light and dark mode functionality for some components * added mobile responsive layout for smaller screens and removed redundant props * added ScrollView in replacement of regular div to remove unecessary horizontal bar * added animations for smoother light and dark mode transitions * removed border from buttons and added shadow instead * made score display on mobile reponsive with horizontal scrolling and removed the scroll bar indicators * adjusted header and footer styles to stick on top / bottom when scrolling happens * cleaned up code structure and removed themestyles * adjusted small screen breakpoint to include landscape mode devices * added function to generate and store session token on load within the frontend alone * replaced logic used to display score sheet from app.py to score.ts * added basic cursor movement logic based on step and speed input * removed node modules from being tracked * updated comments and formatted code * implemented file upload logic for standalone app * converted features.py and added real-time chroma feature extraction from live audio input * Fix: resolved linter errors for AudioWorklet globals * upgraded SDK and made changes for Expo app compatibility * added live mic for expo + UI adjustments + adjust cursor to move by beats * reduced zoom of OSMD display for better UI * added same beat movement logic to script for expo app version * added / modified comments for clarity * added Peter's OTW and ScoreFollower files * quick expo app upgrade from SDK52 to SDK53 due to recent expo app update * adjusted OSMD display on web for smaller devices
1 parent 23e7a90 commit 81dcb77

File tree

10 files changed

+3744
-6157
lines changed

10 files changed

+3744
-6157
lines changed

frontend/companion-app/App.tsx

Lines changed: 178 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
// Import necessary modules and components from the Expo and React Native libraries
22
import { StatusBar } from "expo-status-bar";
3-
import { StyleSheet, Text, View, Image, SafeAreaView, TouchableOpacity, useWindowDimensions, ScrollView, TextStyle, Animated } from "react-native";
3+
import { StyleSheet, Text, View, Image, SafeAreaView, TouchableOpacity, useWindowDimensions, ScrollView, TextStyle, Animated, Platform } from "react-native";
44
import React, { useEffect, useReducer, useRef, useState } from "react";
55
import { startSession, synchronize } from "./components/Utils";
66
import { Score_Select } from "./components/ScoreSelect";
@@ -17,6 +17,7 @@ import { SynthesizeButton } from "./components/SynthesizeButton";
1717
import Icon from 'react-native-vector-icons/Feather';
1818
import { ChromaMaker } from "./utils/features";
1919
import FontAwesome from 'react-native-vector-icons/FontAwesome';
20+
import { ExpoMicProcessor } from './utils/ExpoMicProcessor';
2021

2122
// Define the main application component
2223
export default function App() {
@@ -49,33 +50,37 @@ export default function App() {
4950
},
5051
);
5152

52-
// State used to store session token
53-
const [sessionToken, setSessionToken] = useState<string>("")
54-
55-
// Function used to generate session token using crypto API
56-
const generateSecureSessionToken = (): string => {
57-
return window.crypto.randomUUID();
58-
};
59-
60-
// On load, call generateSecureSessionToken function to generate and store new session token
61-
useEffect(() => {
62-
const newToken: string = generateSecureSessionToken();
63-
setSessionToken(newToken)
64-
}, []);
65-
66-
// Initialize the chroma state as an array of 12 zeros (used to capture chroma vector at each chunk of audio).
67-
const [chroma, setChroma] = useState<number[]>(new Array(12).fill(0));
53+
const [chroma, setChroma] = useState<number[]>(new Array(12).fill(0)); // Initialize the chroma state as an array of 12 zeros (used to capture chroma vector at each chunk of audio).
6854
const [started, setStarted] = useState(false); // state used to determine user selects live microphone option or not
55+
const processor = useRef(new ExpoMicProcessor()).current; // Create a stable ExpoMicProcessor instance that persists across renders
56+
const SAMPLE_RATE = 44100; // Define sample rate for ChromaMaker
57+
const N_FFT = 4096; // Define chunk size for ChromaMaker
58+
const chromaMaker = useRef(new ChromaMaker(SAMPLE_RATE, N_FFT)).current; // Create a stable ChromaMaker instance that persists across renders
6959

60+
// Create an array of Animated.Value objects for a smooth height animation of each chroma bar.
61+
// const animatedChroma = useRef(new Array(12).fill(0).map(() => new Animated.Value(0))).current;
62+
63+
// // Whenever the chroma state updates, animate each corresponding Animated.Value.
64+
// useEffect(() => {
65+
// chroma.forEach((value, idx) => {
66+
// Animated.timing(animatedChroma[idx], {
67+
// toValue: value * 200, // scale factor to adjust maximum bar height
68+
// duration: 50,
69+
// useNativeDriver: false
70+
// }).start();
71+
// });
72+
// }, [chroma]);
73+
7074
useEffect(() => {
7175
let audioCtx: AudioContext; // Declare a reference to the AudioContext, which manages all audio processing
7276
let micStream: MediaStream; // Declare a reference to the MediaStream from the user's microphone
7377

74-
const initAudio = async () => {
78+
// Web version of intializing miccrophone (Uses AudioWorklet Node)
79+
const initWebAudio = async () => {
7580
try {
7681
micStream = await navigator.mediaDevices.getUserMedia({ audio: true }); // Request access to user's microphone
7782
audioCtx = new AudioContext(); // Create a new AudioContext for audio processing
78-
await audioCtx.audioWorklet.addModule('../utils/mic-processor.js'); // Load the custom AudioWorkletProcessor
83+
await audioCtx.audioWorklet.addModule('./utils/mic-processor.js'); // Load the custom AudioWorkletProcessor
7984
const source = audioCtx.createMediaStreamSource(micStream); // Create a source node from the microphone stream
8085
const workletNode = new AudioWorkletNode(audioCtx, 'mic-processor'); // Create an AudioWorkletNode linked to our custom 'mic-processor'
8186
source.connect(workletNode); // Connect the mic source to the worklet
@@ -100,17 +105,47 @@ export default function App() {
100105
console.error('Failed to initialize audio:', err);
101106
}
102107
};
103-
// If "started" state is true, initialize audio processing
108+
109+
// Mobile version of intializing miccrophone (Uses ExpoMicProcessor)
110+
const initNativeAudio = async () => {
111+
try {
112+
await processor.init(); // ExpoMicProcessor intialization
113+
114+
processor.onmessage = ({ data }) => { // Once we get buffer of size 4096
115+
const vec = chromaMaker.insert(data); // Insert with ChromaMaker to get chroma vector
116+
setChroma(vec); // Set chroma vector
117+
};
118+
119+
await processor.start(); // Start recording
120+
} catch (err) {
121+
console.error('Failed to initialize Native audio:', err);
122+
}
123+
};
124+
125+
// If "started" state is true, initialize audio processing based on platform
104126
if (started) {
105-
initAudio();
106-
}
107-
127+
if (Platform.OS === 'web') {
128+
initWebAudio(); // Use browser audio processor
129+
} else {
130+
initNativeAudio(); // Use native Expo/React Native audio processor
131+
}
132+
}
133+
108134
// Cleanup: when the component unmounts or `started` becomes false,
109135
// stop the microphone stream and close the audio context to free up resources
110136
return () => {
111-
if (micStream) micStream.getTracks().forEach((track) => track.stop());
112-
if (audioCtx) audioCtx.close();
113-
};
137+
138+
// Web version of microphone stop
139+
if (Platform.OS === 'web') {
140+
if (micStream) micStream.getTracks().forEach((track) => track.stop());
141+
if (audioCtx) audioCtx.close();
142+
143+
// Mobile version of microphone stop
144+
} else {
145+
processor.stop();
146+
}
147+
};
148+
114149
}, [started]);
115150

116151
////////////////////////////////////////////////////////////////////////////////
@@ -236,6 +271,7 @@ export default function App() {
236271
const backgroundColorAnim = useRef(new Animated.Value(0)).current;
237272
const textColorAnim = useRef(new Animated.Value(0)).current;
238273
const borderBottomAnim = useRef(new Animated.Value(0)).current;
274+
239275
// const borderColorAnim = useRef(new Animated.Value(0)).current;
240276

241277
// Interpolate background color based on light or dark mode
@@ -268,17 +304,14 @@ export default function App() {
268304
inputRange: [0, 1],
269305
outputRange: ["#2C3E50", "#FFFFFF"], // Light to dark
270306
});
271-
// Interpolate header and footer container color based on light or dark mode
272-
const menubarBackgroundColor = backgroundColorAnim.interpolate({
273-
inputRange: [0, 1],
274-
outputRange: ["#2C3E50", "#1A252F"], // Light to dark
275-
});
276307
// Interpolate border bottom color based on light or dark mode
277308
const borderBottomColor = borderBottomAnim.interpolate({
278309
inputRange: [0, 1],
279310
outputRange: ["#2C3E50", "#FFFFFF"], // Light to dark transition
280311
});
281312

313+
314+
282315
// Toggles between light and dark mode by animating background, text, and border properties smoothly
283316
const toggleTheme = () => {
284317
const toValue = theme === "light" ? 1 : 0;
@@ -303,33 +336,40 @@ export default function App() {
303336
});
304337
};
305338
// Get device's width
306-
const { width } = useWindowDimensions()
339+
const { width, height } = useWindowDimensions()
307340
// Boolean used for dynmaic display (row or column)
308341
const isSmallScreen = width < 960;
342+
// const noteLabels = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'];
309343

310344
////////////////////////////////////////////////////////////////////////////////
311345
// Render the component's UI
312346
////////////////////////////////////////////////////////////////////////////////
313347
return (
314-
<SafeAreaView style={[styles.container]}>
315-
{/* Header with image */}
316-
<Animated.View style={[styles.menu_bar, {backgroundColor: menubarBackgroundColor, height: isSmallScreen? 40: 80}]}>
317-
<Image source={require('./assets/companion.png')} style={[styles.logo, {height: isSmallScreen? 30: 100, width: isSmallScreen? 100: 200}]}/>
318-
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 10 }}>
319-
<TouchableOpacity onPress={() => setStarted(!started)}>
320-
<FontAwesome
321-
name={started ? 'microphone' : 'microphone-slash'}
322-
size={isSmallScreen ? 15 : 30}
323-
color="white"
324-
/>
325-
</TouchableOpacity>
326-
<TouchableOpacity onPress={toggleTheme}>
327-
<Icon name={theme === 'light' ? 'sun' : 'moon'} size={isSmallScreen? 15: 30} color="white" />
328-
</TouchableOpacity>
329-
330-
</View>
331-
332-
</Animated.View>
348+
349+
// BG Color for iphone padding - no padding if on landscape mode (top and bottom)
350+
<SafeAreaView style={[styles.container, {backgroundColor: width < height? '#2C3E50': ""}]} >
351+
352+
{/* Account for top padding on Iphone */}
353+
<SafeAreaView >
354+
{/* Header with image */}
355+
<Animated.View style={[styles.menu_bar, {backgroundColor: '#2C3E50', height: isSmallScreen? 40: 80}, { position: 'relative', top: 0 }]}>
356+
<Image source={require('./assets/companion.png')} style={[styles.logo, {height: isSmallScreen? 30: 100, width: isSmallScreen? 100: 200}]}/>
357+
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 10 }}>
358+
<TouchableOpacity onPress={() => setStarted(!started)}>
359+
<FontAwesome
360+
name={started ? 'microphone' : 'microphone-slash'}
361+
size={isSmallScreen ? 15 : 30}
362+
color="white"
363+
/>
364+
</TouchableOpacity>
365+
<TouchableOpacity onPress={toggleTheme}>
366+
<Icon name={theme === 'light' ? 'sun' : 'moon'} size={isSmallScreen? 15: 30} color="white" />
367+
</TouchableOpacity>
368+
</View>
369+
</Animated.View>
370+
</SafeAreaView>
371+
372+
333373

334374
{/* Provides safe area insets for mobile devices */}
335375
<Animated.View style={[styles.container, { backgroundColor: containerBackgroundColor }]}>
@@ -344,7 +384,7 @@ export default function App() {
344384
{/* Sidebar for inputs and buttons (takes up little width) */}
345385
<Animated.View style={[styles.sidebar, { backgroundColor: sidebarBackgroundColor }, isSmallScreen ? styles.sidebarColumn : {}]}>
346386
{ // List of scores, show when not in play mode
347-
state.inPlayMode || <Score_Select state={state} dispatch={dispatch} textStyle={textColor} borderStyle={borderBottomColor}/> }
387+
state.inPlayMode || <Score_Select state={state} dispatch={dispatch} textStyle={textColor} borderStyle={borderBottomColor} button_text_style={invertTextColor} button_format={[styles.button, {backgroundColor: buttonBackgroundColor}]}/> }
348388
<Return_Button
349389
state={state}
350390
dispatch={dispatch}
@@ -378,22 +418,40 @@ export default function App() {
378418

379419
{/* Scroll View used for horizontal scolling */}
380420
<ScrollView
381-
horizontal={true}
421+
horizontal={false}
382422
showsHorizontalScrollIndicator={false}
383423
contentContainerStyle={{ flexGrow: 1 }} // Ensure the content fills the container
384424
>
385425
{/* Actual content display (takes up remaining width after sidebar) */}
386426
<Animated.View style={[styles.mainContent, {backgroundColor: mainContentBackgroundColor}, isSmallScreen ? styles.mainContentColumn : {}]}>
387-
<ScoreDisplay state={state} dispatch={dispatch} />
427+
<ScoreDisplay state={state} dispatch={dispatch}/>
388428
</Animated.View>
389429
</ScrollView>
390430
</View>
431+
432+
{/* Chroma Vector Display - commented out just in case we need to check if mic works */}
433+
{/* <View style={chromaStyles.container}>
434+
<Text style={chromaStyles.infoText}>Live Chroma Visualization</Text>
435+
<View style={chromaStyles.chromaContainer}>
436+
{chroma.map((value, idx) => (
437+
<View key={idx} style={chromaStyles.chromaBarContainer}>
438+
<Animated.View style={[chromaStyles.chromaBar, { height: animatedChroma[idx] }]} />
439+
<Text style={chromaStyles.chromaLabel}>{noteLabels[idx]}</Text>
440+
</View>
441+
))}
442+
</View>
443+
444+
</View> */}
391445
{/* Footer display for status */}
392446
<StatusBar style="auto" />
393447
{/* Automatically adjusts the status bar style */}
394448
</ScrollView>
395449
</Animated.View>
396-
<AudioPlayer state={state} menuStyle={{ backgroundColor: menubarBackgroundColor }}/>
450+
451+
{/* Account for bottom padding on Iphone */}
452+
{/* <SafeAreaView>
453+
<AudioPlayer state={state} menuStyle={{ backgroundColor: '#2C3E50' }}/>
454+
</SafeAreaView> */}
397455
</SafeAreaView>
398456
);
399457
}
@@ -432,7 +490,7 @@ const styles = StyleSheet.create({
432490
gap: 10,
433491
flex: 1,
434492
padding: 20,
435-
marginTop: 80 // account for fixed header
493+
marginTop: 10 // account for fixed header
436494
},
437495
// Container displaying sidebar and main content (row form)
438496
contentWrapperRow: {
@@ -503,4 +561,67 @@ const styles = StyleSheet.create({
503561
color: "#FFFFFF",
504562
fontWeight: "bold",
505563
},
506-
});
564+
});
565+
566+
// const chromaStyles = StyleSheet.create({
567+
// container: {
568+
// flex: 1,
569+
// alignItems: 'center',
570+
// justifyContent: 'center',
571+
// backgroundColor: '#FAFAFA', // Slight off-white for a subtle card look
572+
// marginVertical: 20,
573+
// padding: 15,
574+
// borderRadius: 10,
575+
// shadowColor: "#000",
576+
// shadowOffset: { width: 0, height: 2 },
577+
// shadowOpacity: 0.1,
578+
// shadowRadius: 3,
579+
// elevation: 3,
580+
// },
581+
// startButton: {
582+
// backgroundColor: '#2196F3',
583+
// paddingVertical: 15,
584+
// paddingHorizontal: 25,
585+
// borderRadius: 10,
586+
// marginBottom: 15, // Added margin for better separation
587+
// },
588+
// startButtonText: {
589+
// color: '#fff',
590+
// fontSize: 18,
591+
// fontWeight: '600',
592+
// },
593+
// infoText: {
594+
// marginTop: 10,
595+
// fontSize: 16,
596+
// fontWeight: '500',
597+
// color: '#333', // Dark text for clear contrast
598+
// },
599+
// chromaContainer: {
600+
// flexDirection: 'row',
601+
// alignItems: 'flex-end',
602+
// height: 220, // Slightly taller for clarity
603+
// width: '95%',
604+
// backgroundColor: '#F0F0F0', // Differentiated background color
605+
// borderWidth: 1,
606+
// borderColor: '#ccc',
607+
// borderRadius: 10,
608+
// marginTop: 20,
609+
// padding: 10,
610+
// },
611+
// chromaBarContainer: {
612+
// flex: 1,
613+
// alignItems: 'center',
614+
// marginHorizontal: 4,
615+
// },
616+
// chromaBar: {
617+
// width: '100%',
618+
// backgroundColor: '#2196F3',
619+
// borderRadius: 4, // Soften edges of chroma bars
620+
// },
621+
// chromaLabel: {
622+
// marginTop: 4,
623+
// fontSize: 12,
624+
// color: '#555',
625+
// fontWeight: '500',
626+
// },
627+
// });

frontend/companion-app/app.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
"name": "companion-app",
44
"slug": "companion-app",
55
"version": "1.0.0",
6-
"orientation": "portrait",
6+
"orientation": "default",
77
"icon": "./assets/icon.png",
88
"userInterfaceStyle": "light",
99
"splash": {

0 commit comments

Comments
 (0)