diff --git a/apps/react/src/App.tsx b/apps/react/src/App.tsx index 7ae3fa91..22042a93 100644 --- a/apps/react/src/App.tsx +++ b/apps/react/src/App.tsx @@ -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'; @@ -27,6 +28,7 @@ export default function App() { return ( + } />} /> diff --git a/apps/react/src/components/MidiInputsDropdown.tsx b/apps/react/src/components/MidiInputsDropdown.tsx index e2347a21..598215ca 100644 --- a/apps/react/src/components/MidiInputsDropdown.tsx +++ b/apps/react/src/components/MidiInputsDropdown.tsx @@ -9,23 +9,23 @@ export const MidiInputsDropdown: React.FunctionComponent 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 (
- {/* ({ - label: device.name, - onClick: () => { - dispatch(midiActions.setSelectedOutput(device.id)); - }, - }))} - /> */} + { diff --git a/apps/react/src/components/PianoSound.tsx b/apps/react/src/components/PianoSound.tsx new file mode 100644 index 00000000..1afa1973 --- /dev/null +++ b/apps/react/src/components/PianoSound.tsx @@ -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(null); + const activeNotesRef = useRef>(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; +}; diff --git a/packages/MemoryFlashCore/src/redux/slices/midiSlice.test.ts b/packages/MemoryFlashCore/src/redux/slices/midiSlice.test.ts new file mode 100644 index 00000000..6dfc1a42 --- /dev/null +++ b/packages/MemoryFlashCore/src/redux/slices/midiSlice.test.ts @@ -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); + }); +}); diff --git a/packages/MemoryFlashCore/src/redux/slices/midiSlice.ts b/packages/MemoryFlashCore/src/redux/slices/midiSlice.ts index 36b69dae..485b0105 100644 --- a/packages/MemoryFlashCore/src/redux/slices/midiSlice.ts +++ b/packages/MemoryFlashCore/src/redux/slices/midiSlice.ts @@ -20,6 +20,7 @@ export interface MidiReduxState { availableMidiDevices: MidiInput[]; selectedInput?: string; selectedOutput?: string; + pianoSamplesEnabled: boolean; } const initialState: MidiReduxState = { @@ -28,6 +29,7 @@ const initialState: MidiReduxState = { waitingUntilEmpty: false, waitingUntilEmptyNotes: [], availableMidiDevices: [], + pianoSamplesEnabled: false, }; const midiSlice = createSlice({ @@ -99,6 +101,9 @@ const midiSlice = createSlice({ setSelectedOutput(state, action: PayloadAction) { state.selectedOutput = action.payload; }, + setPianoSamplesEnabled(state, action: PayloadAction) { + state.pianoSamplesEnabled = action.payload; + }, waitUntilEmpty(state) { state.waitingUntilEmpty = true; state.waitingUntilEmptyNotes = state.notes;