Skip to content

Commit

Permalink
refactor: refactored time scheduling in DrumMachine and Metronome
Browse files Browse the repository at this point in the history
  • Loading branch information
Maciej Makowski committed Sep 26, 2024
1 parent c4d5a04 commit 8757057
Show file tree
Hide file tree
Showing 2 changed files with 153 additions and 107 deletions.
116 changes: 89 additions & 27 deletions apps/common-app/src/examples/DrumMachine/DrumMachine.tsx
Original file line number Diff line number Diff line change
@@ -1,64 +1,126 @@
import { useState, useRef, useEffect, FC } from 'react';
import { Text } from 'react-native';
import { Text, View } from 'react-native';
import { AudioContext } from 'react-native-audio-api';

import Container from '../../components/Container';
import Steps from '../../components/Steps';
import PlayPauseButton from '../../components/PlayPauseButton';
import Spacer from '../../components/Spacer';
import Slider from '../../components/Slider';
import { Sounds, SoundSteps } from '../../types';
import { SoundEngine, Kick, Clap, HiHat } from '../SharedUtils';
import { View } from 'react-native';

const STEPS: SoundSteps = {
kick: new Array(8).fill(false),
hihat: new Array(8).fill(false),
clap: new Array(8).fill(false),
};
import { Sounds, SoundName } from '../../types';
import { Kick, Clap, HiHat, Scheduler } from '../SharedUtils';

const STEPS: Sounds = [
{ name: 'kick', steps: new Array(8).fill(false) },
{ name: 'clap', steps: new Array(8).fill(false) },
{ name: 'hihat', steps: new Array(8).fill(false) },
];

const DrumMachine: FC = () => {
const audioContextRef = useRef<AudioContext | null>(null);
const soundEngines = useRef<Record<Sounds, SoundEngine>>(
{} as Record<Sounds, SoundEngine>
);
const [sounds, setSounds] = useState<SoundSteps>(STEPS);
const schedulerRef = useRef<null | Scheduler>(null);
const kickRef = useRef<Kick | null>(null);
const hiHatRef = useRef<HiHat | null>(null);
const clapRef = useRef<Clap | null>(null);

const [sounds, setSounds] = useState<Sounds>(STEPS);
const [isPlaying, setIsPlaying] = useState(false);
const [bpm, setBpm] = useState<number>(120);

const handleStepClick = (name: Sounds, idx: number) => {
const handleStepClick = (name: SoundName, idx: number) => {
setSounds((prevSounds) => {
const newSounds = { ...prevSounds };
const steps = newSounds[name];
const newSounds = [...prevSounds];
const steps = newSounds.find((sound) => sound.name === name)?.steps;
if (steps) {
const newSteps = [...steps];
newSteps[idx] = !newSteps[idx];
newSounds[name] = newSteps;
steps[idx] = !steps[idx];
}
if (schedulerRef.current) {
schedulerRef.current.steps = newSounds;
}
return newSounds;
});
};

const handleBpmChange = (newBpm: number) => {
handlePause();
setBpm(newBpm);
if (schedulerRef.current) {
schedulerRef.current.bpm = newBpm;
}
};

const handlePause = () => {
setIsPlaying(false);
schedulerRef.current?.stop();
};

const handlePlayPause = () => {
if (!audioContextRef.current || !schedulerRef.current) {
return;
}

if (!isPlaying) {
setIsPlaying(true);
schedulerRef.current.start();
} else {
setIsPlaying(false);
schedulerRef.current.stop();
}
};

const playSound = (name: SoundName, time: number) => {
if (!audioContextRef.current || !schedulerRef.current) {
return;
}

setIsPlaying(false);
if (!kickRef.current) {
kickRef.current = new Kick(audioContextRef.current);
}

if (!hiHatRef.current) {
hiHatRef.current = new HiHat(audioContextRef.current);
}

if (!clapRef.current) {
clapRef.current = new Clap(audioContextRef.current);
}

switch (name) {
case 'kick':
kickRef.current.play(time);
break;
case 'hihat':
hiHatRef.current.play(time);
break;
case 'clap':
clapRef.current.play(time);
break;
default:
break;
}
};

useEffect(() => {
if (!audioContextRef.current) {
audioContextRef.current = new AudioContext();
soundEngines.current.kick = new Kick(audioContextRef.current);
soundEngines.current.hihat = new HiHat(audioContextRef.current);
soundEngines.current.clap = new Clap(audioContextRef.current);
}

if (!schedulerRef.current) {
const scheduler = new Scheduler(
bpm,
8,
audioContextRef.current,
STEPS,
playSound
);
schedulerRef.current = scheduler;
}

return () => {
schedulerRef.current?.stop();
audioContextRef.current?.close();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);

return (
Expand All @@ -71,17 +133,17 @@ const DrumMachine: FC = () => {
<Text>bpm: {bpm}</Text>
<Slider
value={bpm}
onValueChange={setBpm}
onValueChange={handleBpmChange}
minimumValue={30}
maximumValue={240}
step={1}
/>
<Spacer.Vertical size={20} />
<View>
{Object.entries(sounds).map(([name, steps]) => (
{sounds.map(({ name, steps }) => (
<Steps
key={name}
name={name as Sounds}
name={name as SoundName}
steps={steps}
handleStepClick={handleStepClick}
/>
Expand Down
144 changes: 64 additions & 80 deletions apps/common-app/src/examples/Metronome/Metronome.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,135 +6,119 @@ import Slider from '../../components/Slider';
import Container from '../../components/Container';
import PlayPauseButton from '../../components/PlayPauseButton';
import Spacer from '../../components/Spacer';

const SCHEDULE_INTERVAL_MS = 25;
const SCHEDULE_AHEAD_TIME = 0.1;
import { Scheduler, MetronomeSound } from '../SharedUtils';
import { Sounds, SoundName } from '../../types';

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 STEPS: Sounds = [
{ name: 'downbeat', steps: [true, false, false, false] },
{ name: 'regularbeat', steps: [false, true, true, true] },
];

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 handlePlay = () => {
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);
};
const downbeatSoundRef = useRef<null | MetronomeSound>(null);
const regularbeatSoundRef = useRef<null | MetronomeSound>(null);
const schedulerRef = useRef<null | Scheduler>(null);

const handlePause = () => {
setIsPlaying(false);

if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
schedulerRef.current?.stop();
};

const handlePlayPause = () => {
if (!audioContextRef.current || !schedulerRef.current) {
return;
}

if (isPlaying) {
handlePause();
return;
}

handlePlay();
setIsPlaying(true);
schedulerRef.current.start();
};

const handleBpmChange = (newBpm: number) => {
setBpm(newBpm);
handlePause();
setBpm(newBpm);
if (schedulerRef.current) {
schedulerRef.current.bpm = newBpm;
}
};

const handleBeatsPerBarChange = (newBeatsPerBar: number) => {
setBeatsPerBar(newBeatsPerBar);
handlePause();
setBeatsPerBar(newBeatsPerBar);
if (schedulerRef.current) {
schedulerRef.current.beatsPerBar = newBeatsPerBar;
const steps = new Array(newBeatsPerBar).fill(false);
steps[0] = true;

schedulerRef.current.steps = [
{ name: 'downbeat', steps },
{ name: 'regularbeat', steps: steps.map((value) => !value) },
];
}
};

const scheduler = () => {
if (!audioContextRef.current) {
const playSound = (name: SoundName, time: number) => {
if (!audioContextRef.current || !schedulerRef.current) {
return;
}

while (
nextNoteTimeRef.current <
audioContextRef.current.currentTime + SCHEDULE_AHEAD_TIME
) {
playNote(currentBeatRef.current, nextNoteTimeRef.current);
nextNote();
if (!downbeatSoundRef.current) {
downbeatSoundRef.current = new MetronomeSound(
audioContextRef.current,
DOWN_BEAT_FREQUENCY
);
}
};

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

currentBeatRef.current += 1;
if (currentBeatRef.current === beatsPerBar) {
currentBeatRef.current = 0;
if (!regularbeatSoundRef.current) {
regularbeatSoundRef.current = new MetronomeSound(
audioContextRef.current,
REGULAR_BEAT_FREQUENCY
);
}
};

const playNote = (beatNumber: number, time: number) => {
if (!audioContextRef.current) {
return;
switch (name) {
case 'downbeat':
downbeatSoundRef.current.play(time);
break;
case 'regularbeat':
regularbeatSoundRef.current.play(time);
break;
default:
break;
}

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(() => {
if (!audioContextRef.current) {
if (!audioContextRef.current || !schedulerRef.current) {
audioContextRef.current = new AudioContext();
}

if (!schedulerRef.current) {
schedulerRef.current = new Scheduler(
bpm,
beatsPerBar,
audioContextRef.current,
STEPS,
playSound
);
}
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
}

schedulerRef.current?.stop();
audioContextRef.current?.close();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);

return (
Expand Down

0 comments on commit 8757057

Please sign in to comment.