/** * External dependencies */ import type * as Tone from 'tone'; import { KeyboardEvent } from 'react'; /** * WordPress dependencies */ import { __ } from '@wordpress/i18n'; import { useEffect, useState, useRef } from '@wordpress/element'; /** * Internal dependencies */ import { DEFAULT_ENVELOPE, INSTRUMENTS } from '../../constants'; import { KEYBOARD_LAYOUTS } from '../../keyboard-layout'; import Loading from '../loading'; import Keyboard from '../keyboard'; import Controls from '../controls'; import { getNormalizedVolume, getSamplerFileNames } from '../../utils'; import type { BlockAttributes, Key } from '../../constants'; type Props = { settings: BlockAttributes; onChange: ( newSettings: Partial< BlockAttributes > ) => void; }; const Piano = ( { settings, onChange }: Props ) => { const { assetsUrl } = window.pianoBlockVars; const { volume, useSustainPedal, octaveOffset, instrument, synthesizerSetting, keyLayout, keyIndicator, } = settings; const [ piano, setPiano ] = useState< Tone.Sampler | Tone.PolySynth >(); const [ isStarted, setIsStarted ] = useState( false ); const [ isReady, setIsReady ] = useState( false ); const [ activeKeys, setActiveKeys ] = useState< Key[] >( [] ); const [ instrumentOctaveOffset, setInstrumentOctaveOffset ] = useState( 0 ); const ref = useRef< HTMLDivElement >( null ); const toneRef = useRef< typeof import('tone') >(); const keys: Key[] = KEYBOARD_LAYOUTS.find( ( { value } ) => value === keyLayout )?.keys || KEYBOARD_LAYOUTS[ 0 ].keys; // Create Tone.js Player. Defer until the first user gesture so the // AudioContext is not created on page load. useEffect( () => { const Tone = toneRef.current; if ( ! isStarted || ! Tone ) { return; } const instrumentSetting = INSTRUMENTS.find( ( { value } ) => value === instrument ); if ( ! instrumentSetting ) { return; } if ( piano ) { piano.dispose(); } setActiveKeys( [] ); setIsReady( false ); let tonePlayer: Tone.Sampler | Tone.PolySynth; if ( instrumentSetting.value === 'synthesizer' ) { tonePlayer = new Tone.PolySynth().toDestination(); tonePlayer.set( { ...synthesizerSetting, envelope: synthesizerSetting.envelope || DEFAULT_ENVELOPE, } ); setInstrumentOctaveOffset( 0 ); setIsReady( true ); } else { tonePlayer = new Tone.Sampler( { urls: getSamplerFileNames( [ ...instrumentSetting.notes ] ), release: 1, baseUrl: `${ assetsUrl }/instruments/${ instrument }/`, onload: () => { if ( ref.current ) { setInstrumentOctaveOffset( instrumentSetting.octaveOffset || 0 ); setIsReady( true ); } }, } ).toDestination(); } tonePlayer.volume.value = getNormalizedVolume( volume, settings ); setPiano( tonePlayer ); return () => { if ( tonePlayer ) { tonePlayer.dispose(); } }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [ instrument, isStarted ] ); // Normalize Volume. useEffect( () => { if ( piano ) { piano.volume.value = getNormalizedVolume( volume, settings ); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [ volume, settings.synthesizerSetting ] ); // Release all sounds. useEffect( () => { if ( piano ) { piano.releaseAll(); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [ settings.octaveOffset, settings.useSustainPedal, settings.synthesizerSetting ] ); // Trigger the note corresponding to the pressed key. const onKeyDown = async ( event: KeyboardEvent ): Promise< void > => { // Load Tone.js and create the AudioContext on the first user gesture, then // let the effect build the player. This first key press only unlocks audio. if ( ! isStarted ) { const Tone = await import( /* webpackPrefetch: true */ 'tone' ); toneRef.current = Tone; await Tone.start(); setIsStarted( true ); return; } if ( ! isReady || ! piano ) { return; } // Search the pressed key. const targetKey = keys.find( ( key ) => key.name.some( ( name ) => event.key === name ) ); if ( ! targetKey ) { return; } // Do nothing if the key has already been pressed. const isKeyPressed = activeKeys.some( ( { note, octave } ) => targetKey.note === note && targetKey.octave === octave ); if ( isKeyPressed ) { return; } // Trigger the note. const targetNote = `${ targetKey.note }${ targetKey.octave + octaveOffset + instrumentOctaveOffset }`; piano.triggerAttack( targetNote, piano.context.currentTime ); setActiveKeys( [ ...activeKeys, targetKey ] ); }; // Stop the note when the key is released. const onKeyUp = ( event: KeyboardEvent ) => { if ( ! isReady || ! piano ) { return; } const targetKey = keys.find( ( key ) => key.name.some( ( name ) => event.key === name ) ); if ( ! targetKey ) { return; } // Stop the note. if ( ! useSustainPedal || instrument === 'synthesizer' ) { const targetNote = `${ targetKey.note }${ targetKey.octave + octaveOffset + instrumentOctaveOffset }`; piano.triggerRelease( targetNote ); } const newActiveKeys = activeKeys.filter( ( { note, octave } ) => ! ( targetKey.note === note && targetKey.octave === octave ) ); setActiveKeys( newActiveKeys ); }; // Trigger the note when the key is clicked by the mouse cursor or when the enter key is pressed. const onKeyClick = async ( note: string, octave: number ) => { // Load Tone.js and create the AudioContext on the first user gesture, then // let the effect build the player. This first click only unlocks audio. if ( ! isStarted ) { const Tone = await import( /* webpackPrefetch: true */ 'tone' ); toneRef.current = Tone; await Tone.start(); setIsStarted( true ); return; } if ( ! isReady || ! piano ) { return; } // Trigger the note. const targetNote = `${ note }${ octave + octaveOffset + instrumentOctaveOffset }`; if ( useSustainPedal && instrument !== 'synthesizer' ) { piano.triggerAttack( targetNote, piano.context.currentTime ); } else { piano.triggerAttackRelease( targetNote, 0.2 ); } }; const controlsProps = { settings, piano, onChange: ( newSettings: Partial< BlockAttributes > ) => { onChange( { ...settings, ...newSettings, } ); }, }; const keyboardProps = { activeKeys, keyLayout, keyIndicator, onKeyClick, }; return ( // eslint-disable-next-line jsx-a11y/no-static-element-interactions