Skip to content
Draft
Show file tree
Hide file tree
Changes from all 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: 2 additions & 0 deletions apps/react/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { Provider } from 'react-redux';
import { BrowserRouter, Route, Routes } from 'react-router-dom';
import { StudyScreen } from './components';
import { MidiToRedux } from './components/MidiToRedux';
import { PianoSound } from './components/PianoSound';
import { AuthenticatedRoute } from './components/navigation/Routers';
import { CoursesScreen, NotationInputScreen } from './screens';
import { AllDeckCardsScreen } from './screens/AllDeckCardsScreen';
Expand All @@ -27,6 +28,7 @@ export default function App() {
return (
<Provider store={store}>
<MidiToRedux />
<PianoSound />
<BrowserRouter>
<Routes>
<Route path="/" element={<AuthenticatedRoute screen={<CoursesScreen />} />} />
Expand Down
24 changes: 12 additions & 12 deletions apps/react/src/components/MidiInputsDropdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,23 +9,23 @@ export const MidiInputsDropdown: React.FunctionComponent<MidiInputsDropdownProps
const dispatch = useAppDispatch();
const devices = useAppSelector((state) => state.midi.availableMidiDevices);
const inputs = devices.filter((device) => device.type === 'input');
// const outputs = devices.filter((device) => device.type === 'output');
const selectedInputId = useAppSelector((state) => state.midi.selectedInput);
const selectedInputName = devices.find((input) => input.id === selectedInputId)?.name;
// const selectedOutputId = useAppSelector((state) => state.midi.selectedOutput);
// const selectedOutputName = devices.find((input) => input.id === selectedOutputId)?.name;
const pianoSamplesEnabled = useAppSelector((state) => state.midi.pianoSamplesEnabled);

return (
<div className="flex flex-row gap-4">
{/* <Dropdown
label={selectedOutputName || 'No MIDI Output'}
items={outputs.map((device) => ({
label: device.name,
onClick: () => {
dispatch(midiActions.setSelectedOutput(device.id));
},
}))}
/> */}
<button
onClick={() => dispatch(midiActions.setPianoSamplesEnabled(!pianoSamplesEnabled))}
className={`px-3 py-1 rounded text-sm ${
pianoSamplesEnabled
? 'bg-blue-500 text-white'
: 'bg-gray-200 text-gray-700 dark:bg-gray-700 dark:text-gray-300'
}`}
title="Toggle piano samples"
>
🎹
</button>
<Dropdown
label={selectedInputName || 'No MIDI Input'}
onButtonClick={(e) => {
Expand Down
74 changes: 74 additions & 0 deletions apps/react/src/components/PianoSound.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import React, { useEffect, useRef } from 'react';
import { useAppSelector } from 'MemoryFlashCore/src/redux/store';
import { Midi } from 'tonal';

export const PianoSound: React.FC = () => {
const audioContextRef = useRef<AudioContext | null>(null);
const activeNotesRef = useRef<Map<number, OscillatorNode>>(new Map());
const onNotes = useAppSelector((state) => state.midi.notes);
const pianoSamplesEnabled = useAppSelector((state) => state.midi.pianoSamplesEnabled);

useEffect(() => {
audioContextRef.current = new AudioContext();

return () => {
activeNotesRef.current.forEach((oscillator) => {
try {
oscillator.stop();
} catch (e) {}
});
activeNotesRef.current.clear();
};
}, []);

useEffect(() => {
if (!pianoSamplesEnabled || !audioContextRef.current) return;

const currentNoteNumbers = new Set(onNotes.map((n) => n.number));
const activeNoteNumbers = new Set(activeNotesRef.current.keys());

activeNoteNumbers.forEach((noteNumber) => {
if (!currentNoteNumbers.has(noteNumber)) {
const oscillator = activeNotesRef.current.get(noteNumber);
if (oscillator) {
try {
oscillator.stop();
} catch (e) {}
activeNotesRef.current.delete(noteNumber);
}
}
});

currentNoteNumbers.forEach((noteNumber) => {
if (!activeNoteNumbers.has(noteNumber)) {
const frequency = Midi.midiToFreq(noteNumber);
const oscillator = audioContextRef.current!.createOscillator();
const gainNode = audioContextRef.current!.createGain();

oscillator.type = 'triangle';
oscillator.frequency.setValueAtTime(
frequency,
audioContextRef.current!.currentTime,
);

gainNode.gain.setValueAtTime(0, audioContextRef.current!.currentTime);
gainNode.gain.linearRampToValueAtTime(
0.15,
audioContextRef.current!.currentTime + 0.01,
);
gainNode.gain.exponentialRampToValueAtTime(
0.001,
audioContextRef.current!.currentTime + 2,
);

oscillator.connect(gainNode);
gainNode.connect(audioContextRef.current!.destination);

oscillator.start();
activeNotesRef.current.set(noteNumber, oscillator);
}
});
}, [onNotes, pianoSamplesEnabled]);

return null;
};
26 changes: 26 additions & 0 deletions packages/MemoryFlashCore/src/redux/slices/midiSlice.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { expect } from 'chai';
import { midiReducer, midiActions, MidiReduxState } from './midiSlice';

const baseState: MidiReduxState = {
notes: [],
wrongNotes: [],
waitingUntilEmpty: false,
waitingUntilEmptyNotes: [],
availableMidiDevices: [],
pianoSamplesEnabled: false,
};

describe('midiSlice', () => {
it('initializes with piano samples disabled', () => {
const state = midiReducer(undefined, { type: 'unknown' });
expect(state.pianoSamplesEnabled).to.equal(false);
});

it('toggles piano samples enabled', () => {
let state = midiReducer(baseState, midiActions.setPianoSamplesEnabled(true));
expect(state.pianoSamplesEnabled).to.equal(true);

state = midiReducer(state, midiActions.setPianoSamplesEnabled(false));
expect(state.pianoSamplesEnabled).to.equal(false);
});
});
5 changes: 5 additions & 0 deletions packages/MemoryFlashCore/src/redux/slices/midiSlice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export interface MidiReduxState {
availableMidiDevices: MidiInput[];
selectedInput?: string;
selectedOutput?: string;
pianoSamplesEnabled: boolean;
}

const initialState: MidiReduxState = {
Expand All @@ -28,6 +29,7 @@ const initialState: MidiReduxState = {
waitingUntilEmpty: false,
waitingUntilEmptyNotes: [],
availableMidiDevices: [],
pianoSamplesEnabled: false,
};

const midiSlice = createSlice({
Expand Down Expand Up @@ -99,6 +101,9 @@ const midiSlice = createSlice({
setSelectedOutput(state, action: PayloadAction<string>) {
state.selectedOutput = action.payload;
},
setPianoSamplesEnabled(state, action: PayloadAction<boolean>) {
state.pianoSamplesEnabled = action.payload;
},
waitUntilEmpty(state) {
state.waitingUntilEmpty = true;
state.waitingUntilEmptyNotes = state.notes;
Expand Down