diff --git a/src/renderer/assets/thumbnails/chord-dictionary.jpg b/src/renderer/assets/thumbnails/chord-dictionary.jpg new file mode 100644 index 0000000..a3a5d75 Binary files /dev/null and b/src/renderer/assets/thumbnails/chord-dictionary.jpg differ diff --git a/src/renderer/components/ChordIntervals/ChordIntervals.module.scss b/src/renderer/components/ChordIntervals/ChordIntervals.module.scss index b88ef59..1ec51dc 100644 --- a/src/renderer/components/ChordIntervals/ChordIntervals.module.scss +++ b/src/renderer/components/ChordIntervals/ChordIntervals.module.scss @@ -3,6 +3,7 @@ .base { display: flex; align-items: center; + justify-content: center; height: 6em; } @@ -35,7 +36,7 @@ } .interval--active { - background-color: $color-success-normal; + background-color: $color-primary-normal; } .interval--played { diff --git a/src/renderer/components/ChordName/ChordName.tsx b/src/renderer/components/ChordName/ChordName.tsx index b843767..a674535 100644 --- a/src/renderer/components/ChordName/ChordName.tsx +++ b/src/renderer/components/ChordName/ChordName.tsx @@ -16,6 +16,25 @@ enum ALIAS_NOTATION { symbol = 2, } +function getChordSymbol(chord: ChordNameProps['chord'], notation: ChordNameProps['notation']) { + if (!chord) { + return ''; + } + + if (typeof notation === 'string') { + if (chord.aliases[ALIAS_NOTATION[notation]] !== undefined) { + return chord.tonic + chord.aliases[ALIAS_NOTATION[notation]]; + } + } else if (typeof notation === 'number' && chord.aliases[notation] !== undefined) { + return chord.tonic + chord.aliases[notation]; + } + + if (chord.aliases[ALIAS_NOTATION.short] !== undefined) { + return chord.tonic + chord.aliases[ALIAS_NOTATION.short]; + } + return chord.symbol; +} + export const ChordName: React.FC = ({ className, chord, @@ -26,13 +45,9 @@ export const ChordName: React.FC = ({ }) => { if (!chord) return null; - const symbol = - // eslint-disable-next-line no-nested-ternary - chord.aliases[ALIAS_NOTATION[notation]] !== undefined - ? chord.tonic + chord.aliases[ALIAS_NOTATION[notation]] - : chord.aliases[ALIAS_NOTATION.short] !== undefined - ? chord.tonic + chord.aliases[ALIAS_NOTATION.short] - : chord.symbol; + const symbol = getChordSymbol(chord, notation); + + if (!symbol) return null; const [tonic, type] = tokenizeChord(symbol); const tokens = tokenizeChordType(type); diff --git a/src/renderer/components/ChordName/types.ts b/src/renderer/components/ChordName/types.ts index 0aab563..a89471a 100644 --- a/src/renderer/components/ChordName/types.ts +++ b/src/renderer/components/ChordName/types.ts @@ -3,7 +3,7 @@ import { Chord } from '@tonaljs/chord'; export type ChordNameProps = { className?: string; chord?: Chord | null; - notation?: 'long' | 'short' | 'symbol'; + notation?: 'long' | 'short' | 'symbol' | number; hideRoot?: boolean; highlightAlterations?: boolean; latinSharpsFlats?: boolean; diff --git a/src/renderer/components/Icon/icons/dictionary.react.svg b/src/renderer/components/Icon/icons/dictionary.react.svg new file mode 100644 index 0000000..9cac8f1 --- /dev/null +++ b/src/renderer/components/Icon/icons/dictionary.react.svg @@ -0,0 +1,4 @@ + + + diff --git a/src/renderer/components/Icon/icons/index.tsx b/src/renderer/components/Icon/icons/index.tsx index 574b1d1..531fbb3 100644 --- a/src/renderer/components/Icon/icons/index.tsx +++ b/src/renderer/components/Icon/icons/index.tsx @@ -10,6 +10,7 @@ export const ICON_NAMES = [ 'clock', 'controller', 'cross', + 'dictionary', 'exclamation', 'github', 'heart', diff --git a/src/renderer/helpers/chords.ts b/src/renderer/helpers/chords.ts index c4d4aa0..f556c58 100644 --- a/src/renderer/helpers/chords.ts +++ b/src/renderer/helpers/chords.ts @@ -94,4 +94,8 @@ export function overrideDictionary() { ); } +export function getChordTypes() { + return ChordType.all(); +} + overrideDictionary(); diff --git a/src/renderer/router.tsx b/src/renderer/router.tsx index 1e7940a..786b62f 100644 --- a/src/renderer/router.tsx +++ b/src/renderer/router.tsx @@ -29,6 +29,8 @@ import Licenses from './views/Settings/Licenses'; import packageJSON from '../../package.json'; import icon from '../../assets/icon.svg'; import ChordDisplayNamespaceSettings from './views/Settings/ChordDisplaySettings/ChordDisplayModuleSettings'; +import ChordDictionary from './views/ChordDictionary'; +import ChordDictionaryDetail from './views/ChordDictionary/Detail'; const router = createHashRouter( createRoutesFromElements( @@ -108,6 +110,31 @@ const router = createHashRouter( } /> + , + hasSettings: false, + }} + element={ + + + + } + > + } /> + ( + {params.chordName} + ), + icon: , + }} + element={} + /> + }} diff --git a/src/renderer/views/ChordDictionary/ChordDictionary.module.scss b/src/renderer/views/ChordDictionary/ChordDictionary.module.scss new file mode 100644 index 0000000..4f6ab83 --- /dev/null +++ b/src/renderer/views/ChordDictionary/ChordDictionary.module.scss @@ -0,0 +1,26 @@ +@import 'tokens'; + +.container { + position: relative; + display: flex; + width: 100%; + height: 100%; + flex-direction: column; + justify-content: flex-end; + align-items: center; + overflow: hidden; +} + +.pitchbar { + --Sidebar_minSize: 48px; + --Button_textTransform: none; +} + +.content { + height: 100%; +} + +.chordbar { + --Sidebar_minSize: 200px; + --Button_textTransform: none; +} diff --git a/src/renderer/views/ChordDictionary/ChordDictionary.tsx b/src/renderer/views/ChordDictionary/ChordDictionary.tsx new file mode 100644 index 0000000..258e87d --- /dev/null +++ b/src/renderer/views/ChordDictionary/ChordDictionary.tsx @@ -0,0 +1,87 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { Outlet, useNavigate, useParams } from 'react-router-dom'; +import classnames from 'classnames/bind'; +import { Chord, Note } from 'tonal'; +import { SidebarContainer } from '@la-jarre-a-son/ui'; + +import { useSettings } from 'renderer/contexts/Settings'; +import { NOTE_NAMES, getKeySignature, getNoteInKeySignature } from 'renderer/helpers'; + +import ChordDictionaryChromaMenu from './ChordDictionaryChromaMenu'; +import ChordDictionaryChordMenu from './ChordDictionaryChordMenu'; + +import styles from './ChordDictionary.module.scss'; + +const cx = classnames.bind(styles); + +const ChordDictionary: React.FC = () => { + const { settings } = useSettings(); + const { chordName } = useParams(); + + const navigate = useNavigate(); + const { key, accidentals } = settings.notation; + const keySignature = useMemo( + () => getKeySignature(key, accidentals === 'sharp'), + [key, accidentals] + ); + + const [chroma, setChroma] = useState(null); + const [chordType, setChordType] = useState(null); + + useEffect(() => { + if (chroma !== null && chordType !== null) { + const name = encodeURIComponent( + `${getNoteInKeySignature(NOTE_NAMES[chroma], keySignature.notes)}${chordType}` + ); + + navigate(`./${name}`); + } + return () => {}; + }, [navigate, chroma, chordType, keySignature]); + + useEffect(() => { + const chord = chordName ? Chord.get(chordName) : null; + + if (chord && chord.tonic) { + setChroma(Note.chroma(chord.tonic) ?? null); + } + + if (chord && chord.aliases[0]) { + setChordType(chord.aliases[0]); + } + }, [chordName]); + + return ( + + } + sidebarProps={{ className: cx('pitchbar') }} + contentProps={{ className: cx('content') }} + size="xs" + open + inset + > + } + sidebarProps={{ className: cx('chordbar') }} + contentProps={{ className: cx('content') }} + size="sm" + open + inset + > + + + + ); +}; + +ChordDictionary.defaultProps = {}; + +export default ChordDictionary; diff --git a/src/renderer/views/ChordDictionary/ChordDictionaryChordMenu.tsx b/src/renderer/views/ChordDictionary/ChordDictionaryChordMenu.tsx new file mode 100644 index 0000000..9fc559c --- /dev/null +++ b/src/renderer/views/ChordDictionary/ChordDictionaryChordMenu.tsx @@ -0,0 +1,61 @@ +import React, { useEffect, useMemo, useRef } from 'react'; +import classnames from 'classnames/bind'; +import { Tab, TabList } from '@la-jarre-a-son/ui'; + +import { getChordTypes } from 'renderer/helpers'; + +import styles from './ChordDictionary.module.scss'; + +const cx = classnames.bind(styles); + +type Props = { + selected: string | null; + onSelect: (note: string) => void; +}; + +const ChordDictionaryChordMenu: React.FC = ({ selected, onSelect }) => { + const ref = useRef(); + const chordTypes = useMemo(() => getChordTypes(), []); + + useEffect(() => { + if (ref.current) { + const currentEl: HTMLElement | null = ref.current.querySelector('[aria-selected=true]'); + + if (currentEl) { + if (ref.current.scrollTop > currentEl.offsetTop) { + currentEl.scrollIntoView({ behavior: 'smooth', block: 'start', inline: 'start' }); + } + if ( + ref.current.scrollTop + ref.current.offsetHeight < + currentEl.offsetTop + currentEl.offsetHeight + ) { + currentEl.scrollIntoView({ behavior: 'smooth', block: 'end', inline: 'start' }); + } + } + } + }, [selected]); + + return ( + + {chordTypes.map((chordType) => ( + onSelect(chordType.aliases[0])} + > + {chordType.aliases[0] || 'maj'} + + ))} + + ); +}; + +export default ChordDictionaryChordMenu; diff --git a/src/renderer/views/ChordDictionary/ChordDictionaryChromaMenu.tsx b/src/renderer/views/ChordDictionary/ChordDictionaryChromaMenu.tsx new file mode 100644 index 0000000..99081a2 --- /dev/null +++ b/src/renderer/views/ChordDictionary/ChordDictionaryChromaMenu.tsx @@ -0,0 +1,68 @@ +import React, { useEffect, useRef } from 'react'; +import classnames from 'classnames/bind'; +import { Tab, TabList } from '@la-jarre-a-son/ui'; + +import { + KeySignatureConfig, + NOTE_NAMES, + formatSharpsFlats, + getNoteInKeySignature, +} from 'renderer/helpers'; + +import styles from './ChordDictionary.module.scss'; + +const cx = classnames.bind(styles); + +type Props = { + keySignature: KeySignatureConfig; + selected: number | null; + onSelect: (chroma: number) => void; +}; + +const ChordDictionaryChromaMenu: React.FC = ({ keySignature, selected, onSelect }) => { + const ref = useRef(); + + useEffect(() => { + if (ref.current) { + const currentEl: HTMLElement | null = ref.current.querySelector('[aria-selected=true]'); + + if (currentEl) { + if (ref.current.scrollTop > currentEl.offsetTop) { + currentEl.scrollIntoView({ behavior: 'smooth', block: 'start', inline: 'start' }); + } + if ( + ref.current.scrollTop + ref.current.offsetHeight < + currentEl.offsetTop + currentEl.offsetHeight + ) { + currentEl.scrollIntoView({ behavior: 'smooth', block: 'end', inline: 'start' }); + } + } + } + }, [selected]); + + return ( + + {NOTE_NAMES.map((note, index) => ( + onSelect(index)} + selected={selected === index} + > + + {formatSharpsFlats(getNoteInKeySignature(note, keySignature.notes))} + + + ))} + + ); +}; + +export default ChordDictionaryChromaMenu; diff --git a/src/renderer/views/ChordDictionary/Detail/ChordDetail.module.scss b/src/renderer/views/ChordDictionary/Detail/ChordDetail.module.scss new file mode 100644 index 0000000..2b8cdb2 --- /dev/null +++ b/src/renderer/views/ChordDictionary/Detail/ChordDetail.module.scss @@ -0,0 +1,127 @@ +@import 'tokens'; + +.base { + display: flex; + flex-direction: column; + align-items: center; + padding: 16px; +} + +.chordName { + font-size: min(64px, 6vw); + display: flex; + justify-content: center; + border-bottom: 1px solid rgba(#fff, 0.5); + margin: 0 0 8px; + padding: 0; + width: 100%; + user-select: all; +} + +.title { + display: flex; + margin: $space-lg 0; + text-transform: uppercase; + width: 100%; + align-items: center; + gap: $space-md; + + &::before, &::after { + content: " "; + flex-grow: 1; + flex-basis: 0; + border-bottom: 1px solid $color-neutral-normal; + } +} + +.name { + user-select: all; + text-align: center; + font-size: min(24px, 4vw); +} + +.intervals { + font-size: min(1em, 2vw); +} + +.notation { + margin: auto; + width: 192px; + text-align: center; +} + +.keyboard { + width: 100%; + margin: 16px 0; +} + +.list { + --List-item_textTransform: none; + --List-item_fontSize: 18px; +} + +.columns { + display: flex; + flex-direction: row; + flex-wrap: wrap; + gap: 16px; + width: 100%; +} + +.column { + flex-basis: 320px; + flex-grow: 1; + align-items: center; + justify-content: center; +} + +.inversion { + display: flex; + flex-direction: row; + align-items: center; + flex-wrap: wrap; + width: 100%; + gap: 16px; +} + +.inversionInfo { + flex-basis: 200px; + flex-grow: 0; +} + +.inversionChord { + font-size: 24px; +} + +.inversionInterval { + font-style: italic; +} + +.inversionAltChord { + margin-top: 16px; +} + +.inversionKeyboard { + flex-grow: 1; + flex-basis: 400px; +} + +.inversionNotation { + width: 96px; + margin: auto; +} + +.chordSet { + + display: flex; + flex-direction: row; + flex-wrap: wrap; + gap: 4px; +} + +.chordBadge { + --Badge_textTransform: none; + &:hover { + --Badge_background: #{$color-primary-normal}; + } +} diff --git a/src/renderer/views/ChordDictionary/Detail/ChordDetail.tsx b/src/renderer/views/ChordDictionary/Detail/ChordDetail.tsx new file mode 100644 index 0000000..d833184 --- /dev/null +++ b/src/renderer/views/ChordDictionary/Detail/ChordDetail.tsx @@ -0,0 +1,235 @@ +import React, { useEffect, useMemo, useRef } from 'react'; +import classnames from 'classnames/bind'; +import { Chord, Note } from 'tonal'; +import { Badge, Container, Link, List, ListItem } from '@la-jarre-a-son/ui'; + +import { defaultKeyboardSettings } from 'main/store/defaults'; + +import { getKeySignature, getNoteInKeySignature } from 'renderer/helpers'; + +import { ChordIntervals, ChordName, Notation, PianoKeyboard } from 'renderer/components'; +import { KeyboardSettings } from 'main/types'; + +import { NavLink, useNavigate, useParams } from 'react-router-dom'; +import { useSettings } from 'renderer/contexts/Settings'; +import styles from './ChordDetail.module.scss'; +import { + getAlternativeChords, + getChordInversion, + getSubsetChords, + getSupersetChords, +} from './utils'; + +const KEYBOARD_SETTINGS: KeyboardSettings = { + ...defaultKeyboardSettings, + skin: 'classic', + from: 'C4', + to: 'B6', + label: 'chordNote', + keyName: 'none', + keyInfo: 'tonicAndInterval', + textOpacity: 1, + displaySustained: true, + wrap: true, + sizes: { + radius: 0.4, + height: 4, + ratio: 0.6, + bevel: true, + }, +}; + +const NOTATION_LABELS = ['long', 'short', 'symbol']; + +const cx = classnames.bind(styles); + +const ChordDetail: React.FC = () => { + const ref = useRef(null); + const { chordName } = useParams(); + const navigate = useNavigate(); + + const { settings } = useSettings(); + const { key, accidentals } = settings.notation; + const keySignature = useMemo( + () => getKeySignature(key, accidentals === 'sharp'), + [key, accidentals] + ); + + useEffect(() => { + if (ref.current) { + ref.current.scrollIntoView(true); + } + }, [chordName]); + + if (!chordName) { + return 'Select a chord or search'; + } + + const chord = chordName ? Chord.get(chordName) : null; + + if (!chord) { + return `Cannot find a chord named "${chordName}"`; + } + + const midi = getChordInversion(chord, 0); + const alternativeChords = getAlternativeChords(chord, keySignature); + const subsetChords = getSubsetChords(chord); + const supersetChords = getSupersetChords(chord); + const goToChordDetail = (name: string) => { + navigate(`../${encodeURIComponent(name)}`); + }; + + return ( + +

+ +

+
{chord.name}
+ +
+
+

Intervals

+ +
+
+

Notation

+ +
+
+ +
+
+

Aliases

+ + {chord.aliases.map((alias, index) => ( + + {NOTATION_LABELS[index]} + + ) + } + > + + + ))} + +
+ {!!alternativeChords.length && ( +
+

Other interpretations

+ + {alternativeChords.map((altChord) => ( + goToChordDetail(`${altChord.tonic + altChord.aliases[0]}`)} + > + + + ))} + +
+ )} +
+

Inversions

+ {chord.intervals.map((_, index) => { + if (!index) return null; + + const interval = chord.intervals[index].replace('*', ''); + const root = chord.notes[index]; + const slashChord = { ...chord, root, rootDegree: index }; + const inversionMidi = getChordInversion(chord, index); + const altChord = alternativeChords.find( + (c) => c.tonic && Note.chroma(c.tonic) === Note.chroma(root) + ); + const altChordName = + altChord && altChord.tonic + ? getNoteInKeySignature(altChord.tonic, keySignature.notes) + altChord.aliases[0] + : ''; + + return ( +
+
+ +
inversion on {interval}
+ {altChord && ( +
+ {'see also '} + + {altChordName} + +
+ )} +
+ + +
+ ); + })} +
+ {!!subsetChords.length && ( +
+

Simplified

+
+ {subsetChords.map((c, index) => ( + + + + ))} +
+
+ )} + {!!supersetChords.length && ( +
+

Extended

+
+ {supersetChords.map((c, index) => ( + + + + ))} +
+
+ )} +
+
+ ); +}; + +export default ChordDetail; diff --git a/src/renderer/views/ChordDictionary/Detail/index.tsx b/src/renderer/views/ChordDictionary/Detail/index.tsx new file mode 100644 index 0000000..f9ca17e --- /dev/null +++ b/src/renderer/views/ChordDictionary/Detail/index.tsx @@ -0,0 +1 @@ +export { default } from './ChordDetail'; diff --git a/src/renderer/views/ChordDictionary/Detail/utils.ts b/src/renderer/views/ChordDictionary/Detail/utils.ts new file mode 100644 index 0000000..2cc85da --- /dev/null +++ b/src/renderer/views/ChordDictionary/Detail/utils.ts @@ -0,0 +1,83 @@ +import { Note, Chord, Interval } from 'tonal'; +import { Chord as TChord } from '@tonaljs/chord'; +import * as ChordType from '@tonaljs/chord-type'; + +import { KeySignatureConfig, getNoteInKeySignature, tokenizeChord } from 'renderer/helpers'; +import { detect } from 'renderer/helpers/chord-detect'; + +const midiC4 = Note.midi('C4') as number; + +export function getChordInversion(chord?: TChord, inversion = 0) { + if (!chord) return []; + + const midi: number[] = []; + + const notes = chord.notes + .slice(inversion % chord.notes.length) + .concat(chord.notes.slice(0, inversion % chord.notes.length)); + + for (let n = 0; n < notes.length; n += 1) { + let newMidi = Note.midi(`${notes[n]}4`); + if (newMidi) { + while (newMidi < (midi.length ? midi[midi.length - 1] : midiC4)) { + newMidi += 12; + } + + midi.push(newMidi); + } + } + + return midi; +} +const getChordInfo = (chord: string, keySignatureNotes?: string[]): TChord | null => { + const [tonic, type, root] = tokenizeChord(chord); + if (tonic) { + const tonicInKey = getNoteInKeySignature(tonic, keySignatureNotes); + const rootInKey = getNoteInKeySignature(root, keySignatureNotes); + const c = Chord.getChord(type, tonicInKey); + const rootInterval = Interval.distance(tonicInKey, rootInKey); + const rootDegree = c.intervals.indexOf(rootInterval) + 1; + return { ...c, symbol: chord, root, rootDegree }; + } + + return null; +}; + +export function getAlternativeChords(chord?: TChord, keySignature?: KeySignatureConfig): TChord[] { + if (!chord) return []; + + const chords = detect(chord.notes, { allowOmissions: false }) + .map((c) => getChordInfo(c, keySignature?.notes)) + .filter((c) => c && c.symbol !== chord.symbol) as TChord[]; + + return chords; +} + +export function getSubsetChords(chord?: TChord): TChord[] { + if (!chord || !chord.tonic) return []; + const subset = ChordType.all() + .filter((chordType) => { + return ( + // eslint-disable-next-line no-bitwise + chord.setNum !== chordType.setNum && (chord.setNum & chordType.setNum) === chordType.setNum + ); + }) + .map((chordType) => Chord.getChord(chordType.aliases[0], chord.tonic as string)); + + return subset; +} + +export function getSupersetChords(chord?: TChord): TChord[] { + if (!chord || !chord.tonic) return []; + + const superset = ChordType.all() + .filter((chordType) => { + return ( + // eslint-disable-next-line no-bitwise + chord.setNum !== chordType.setNum && (chord.setNum & chordType.setNum) === chord.setNum + ); + }) + .map((chordType) => Chord.getChord(chordType.aliases[0], chord.tonic as string)); + + return superset; +} diff --git a/src/renderer/views/ChordDictionary/index.tsx b/src/renderer/views/ChordDictionary/index.tsx new file mode 100644 index 0000000..bf79d55 --- /dev/null +++ b/src/renderer/views/ChordDictionary/index.tsx @@ -0,0 +1 @@ +export { default } from './ChordDictionary'; diff --git a/src/renderer/views/Home/Home.tsx b/src/renderer/views/Home/Home.tsx index 7560264..2af0704 100644 --- a/src/renderer/views/Home/Home.tsx +++ b/src/renderer/views/Home/Home.tsx @@ -19,6 +19,7 @@ import { Icon, NavButton } from 'renderer/components'; import ThumbnaildChordDisplay from 'renderer/assets/thumbnails/chord-display.jpg'; import ThumbnaildChordQuiz from 'renderer/assets/thumbnails/chord-quiz.jpg'; +import ThumbnaildChordDictionary from 'renderer/assets/thumbnails/chord-dictionary.jpg'; import ThumbnaildCircleOfFifths from 'renderer/assets/thumbnails/circle-of-fifths.jpg'; import ThumbnailRouting from 'renderer/assets/thumbnails/routing.jpg'; import ThumbnailDebugger from 'renderer/assets/thumbnails/debugger.jpg'; @@ -152,6 +153,28 @@ const Home: React.FC = () => { Circle of Fifths + + + + {overlayEnabled && ( + + + + )} + + }>Chord Dictionary +