Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: implemented metronome #134

Merged
merged 15 commits into from
Sep 25, 2024
Merged
Show file tree
Hide file tree
Changes from 12 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
2 changes: 0 additions & 2 deletions apps/common-app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
"version": "0.0.1",
"private": true,
"peerDependencies": {
"@react-native-community/slider": "*",
"@react-navigation/native": "*",
"@react-navigation/native-stack": "*",
"@react-navigation/stack": "*",
Expand All @@ -18,7 +17,6 @@
},
"devDependencies": {
"@babel/core": "^7.20.0",
"@react-native-community/slider": "^4.5.3",
"@react-navigation/native": "^6.1.18",
"@react-navigation/native-stack": "^6.11.0",
"@react-navigation/stack": "^6.4.1",
Expand Down
24 changes: 13 additions & 11 deletions apps/common-app/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { NavigationContainer, useNavigation } from '@react-navigation/native';
import { createStackNavigator } from '@react-navigation/stack';

import Container from './components/Container';
import { Examples } from './examples';
import { Examples, ExampleKey, Example } from './examples';
import { layout, colors } from './styles';

const Stack = createStackNavigator();
Expand All @@ -16,16 +16,18 @@ const HomeScreen: FC = () => {
return (
<Container centered={false}>
<ScrollView contentContainerStyle={styles.scrollView}>
{Object.entries(Examples).map(([key, example]) => (
<TouchableOpacity
onPress={() => navigation.navigate(key as never)}
key={key}
style={styles.button}
>
<Text style={styles.title}>{example.title}</Text>
<Text style={styles.subtitle}>{example.subtitle}</Text>
</TouchableOpacity>
))}
{Object.entries(Examples).map(
([key, example]: [ExampleKey, Example]) => (
<TouchableOpacity
onPress={() => navigation.navigate(key)}
key={key}
style={styles.button}
>
<Text style={styles.title}>{example.title}</Text>
<Text style={styles.subtitle}>{example.subtitle}</Text>
</TouchableOpacity>
)
)}
</ScrollView>
</Container>
);
Expand Down
124 changes: 124 additions & 0 deletions apps/common-app/src/components/Slider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import React from 'react';
import { FC, useEffect, useCallback } from 'react';
import { View, StyleSheet } from 'react-native';
import {
GestureHandlerRootView,
GestureDetector,
Gesture,
} from 'react-native-gesture-handler';
import Animated, {
useSharedValue,
useAnimatedStyle,
runOnJS,
} from 'react-native-reanimated';

import { layout, colors } from '../styles';

interface SliderProps {
value: number;
onValueChange: (value: number) => void;
minimumValue: number;
maximumValue: number;
step: number;
}

const SLIDER_WIDTH = 300;
const HANDLE_SIZE = 20;
const HANDLE_SPACING = 5;
const MAX_OFFSET = SLIDER_WIDTH - HANDLE_SIZE - 10;

const Slider: FC<SliderProps> = (props) => {
const { value, onValueChange, minimumValue, maximumValue, step } = props;

const offset = useSharedValue(0);

const convertOffsetToValue = useCallback(
(offsetValue: number) => {
const newValue =
minimumValue +
(offsetValue / MAX_OFFSET) * (maximumValue - minimumValue);
const steppedValue = Math.round(newValue / step) * step;
const clampedValue = Math.max(
minimumValue,
Math.min(steppedValue, maximumValue)
);

onValueChange(clampedValue);
},
[minimumValue, maximumValue, step, onValueChange]
);

const convertValueToOffset = useCallback(
(newValue: number) => {
const fraction =
(newValue - minimumValue) / (maximumValue - minimumValue);
return fraction * MAX_OFFSET;
},
[minimumValue, maximumValue]
);

const pan = Gesture.Pan().onChange((event) => {
offset.value =
Math.abs(offset.value) <= MAX_OFFSET
? offset.value + event.changeX <= 0
? 0
: offset.value + event.changeX >= MAX_OFFSET
? MAX_OFFSET
: offset.value + event.changeX
: offset.value;

runOnJS(convertOffsetToValue)(offset.value);
});

const sliderStyle = useAnimatedStyle(() => {
return {
transform: [{ translateX: offset.value }],
};
});

useEffect(() => {
offset.value = convertValueToOffset(value);
}, [
value,
minimumValue,
maximumValue,
convertOffsetToValue,
offset,
convertValueToOffset,
]);

return (
<GestureHandlerRootView style={styles.container}>
<View style={styles.sliderTrack}>
<GestureDetector gesture={pan}>
<Animated.View style={[styles.sliderHandle, sliderStyle]} />
</GestureDetector>
</View>
</GestureHandlerRootView>
);
};

const styles = StyleSheet.create({
container: {
overflow: 'hidden',
padding: layout.spacing,
},
sliderTrack: {
width: SLIDER_WIDTH,
height: HANDLE_SIZE + 10,
backgroundColor: colors.darkblue,
borderRadius: 25,
justifyContent: 'center',
padding: layout.spacing,
},
sliderHandle: {
width: HANDLE_SIZE,
height: HANDLE_SIZE,
backgroundColor: colors.white,
borderRadius: 20,
position: 'absolute',
left: HANDLE_SPACING,
},
});

export default Slider;
132 changes: 129 additions & 3 deletions apps/common-app/src/examples/Metronome/Metronome.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,138 @@
import { Text } from 'react-native';
import { FC } from 'react';
import React, { useState, useEffect, useRef, FC } from 'react';
import { Text, Button } from 'react-native';
import { AudioContext } from 'react-native-audio-api';

import Slider from '../../components/Slider';
import Container from '../../components/Container';
import { colors } from '../../styles';

const SCHEDULE_INTERVAL_MS = 25;
const SCHEDULE_AHEAD_TIME = 0.1;

const DOWN_BEAT_FREQUENCY = 1000;
const REGULAR_BEAT_FREQUENCY = 500;

const INITIAL_GAIN = 1;
const FADE_OUT_START_GAIN = 0.5;
const FADE_OUT_END_GAIN = 0.001;

const FADE_OUT_START_TIME = 0.01;
const FADE_OUT_END_TIME = 0.03;

const Metronome: FC = () => {
const [bpm, setBpm] = useState(120);
const [beatsPerBar, setBeatsPerBar] = useState(4);
const [isPlaying, setIsPlaying] = useState(false);

const audioContextRef = useRef<null | AudioContext>(null);
const intervalRef = useRef<null | NodeJS.Timeout>(null);
const nextNoteTimeRef = useRef(0.0);
const currentBeatRef = useRef(0);

const handlePlayPause = () => {
if (!isPlaying) {
if (!audioContextRef.current) {
audioContextRef.current = new AudioContext();
}

setIsPlaying(true);
currentBeatRef.current = 0;
nextNoteTimeRef.current =
audioContextRef.current.currentTime + SCHEDULE_INTERVAL_MS / 1000;
intervalRef.current = setInterval(scheduler, SCHEDULE_INTERVAL_MS);
} else {
setIsPlaying(false);
if (intervalRef.current) {
clearInterval(intervalRef.current);
michalsek marked this conversation as resolved.
Show resolved Hide resolved
}
}
michalsek marked this conversation as resolved.
Show resolved Hide resolved
};

const scheduler = () => {
if (!audioContextRef.current) {
return;
}

while (
nextNoteTimeRef.current <
audioContextRef.current.currentTime + SCHEDULE_AHEAD_TIME
) {
playNote(currentBeatRef.current, nextNoteTimeRef.current);
nextNote();
}
};

const nextNote = () => {
const secondsPerBeat = 60.0 / bpm;
nextNoteTimeRef.current += secondsPerBeat;

currentBeatRef.current++;
michalsek marked this conversation as resolved.
Show resolved Hide resolved
if (currentBeatRef.current === beatsPerBar) {
currentBeatRef.current = 0;
}
};

const playNote = (beatNumber: number, time: number) => {
if (!audioContextRef.current) {
return;
}

const oscillator = audioContextRef.current.createOscillator();
const gain = audioContextRef.current.createGain();

oscillator.frequency.value =
beatNumber % beatsPerBar === 0
? DOWN_BEAT_FREQUENCY
: REGULAR_BEAT_FREQUENCY;

gain.gain.setValueAtTime(INITIAL_GAIN, time);
gain.gain.linearRampToValueAtTime(
FADE_OUT_START_GAIN,
time + FADE_OUT_START_TIME
);
gain.gain.linearRampToValueAtTime(
FADE_OUT_END_GAIN,
time + FADE_OUT_END_TIME
);

oscillator.connect(gain);
gain.connect(audioContextRef.current.destination);

oscillator.start(time);
oscillator.stop(time + FADE_OUT_END_TIME);
};

useEffect(() => {
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
}
};
}, []);

return (
<Container centered={true}>
michalsek marked this conversation as resolved.
Show resolved Hide resolved
<Text>Metronome</Text>
<Button
color={colors.darkblue}
onPress={handlePlayPause}
title={isPlaying ? 'Pause' : 'Play'}
/>
<Text>BPM: {bpm}</Text>
<Slider
value={bpm}
onValueChange={setBpm}
minimumValue={30}
maximumValue={240}
step={1}
/>
<Text>Beats per bar: {beatsPerBar}</Text>
<Slider
value={beatsPerBar}
onValueChange={setBeatsPerBar}
minimumValue={1}
maximumValue={8}
step={1}
/>
</Container>
);
};
Expand Down
Loading