用React、TypeScript和Next.js打造吉他和弦可视化应用
在这篇文章里,我们将使用React、TypeScript和NextJS为吉他手创建一个应用,该应用可以在指板上可视化音阶。你可以在这里查看最终结果here,也可以在这里查看代码库here。首先,我们将使用RadzionKit这个启动模板,它提供了一系列组件和工具,旨在简化React应用程序的开发,提高工作效率。
我一直都有吉他,经常想出一些吉他独奏片段或riffs和旋律,但因为不懂乐理,所以我很难将它们进一步发展。为了克服这个障碍,我买了一本叫做《Fretboard Theory》的书来学习基础知识,了解吉他指板的知识。书中首先讲的是指板上的音符布局,以及音阶和五声音阶。这激发了我制作一个应用的想法,这个应用可以在指板上可视化所有这些内容,方便在一个地方探索不同的演奏模式。
吉他音阶可视化应用的功能特点我们的应用程序将包含两个主要视图:一个首页,展示指板上的所有音符,以及一个音阶页,在那里你可以选择一个根音和一个音阶类型。在音阶页上,你还可以切换查看完整的音阶或其五声音阶。
确保页面SEO友好虽然这个应用可以作为一个React单页应用来构建,但这种方法对SEO不利。相反,为每个模式创建真正的页面会更合适。我们有两种选择:服务器端渲染和生成静态页面。由于应用只需大约200个页面,生成静态页面会是更好的选择。它成本效益高,因为我们无需支付服务器费用,页面也可以通过CDN免费或低成本提供。
实现主页让我们从创建首页开始,并将它命名为NotesPage
。
import { VStack } from "@lib/ui/css/stack"
import { NotesPageTitle } from "./NotesPageTitle"
import { NotesPageContent } from "./NotesPageContent"
import { PageContainer } from "../layout/PageContainer"
export const NotesPage = () => {
return (
<PageContainer>
<VStack gap={80}>
<NotesPageTitle />
<NotesPageContent />
</VStack>
</PageContainer>
)
}
点击全屏 关闭全屏
我们将首页包裹在一个 PageContainer
组件中,该组件使用了 RadzionKit(GitHub)中的 centeredContentColumn
和 verticalPadding
CSS 工具。这些工具确保内容有响应式的水平间距,最大宽度为 1600px,垂直间距为 80px。
import { centeredContentColumn } from "@lib/ui/css/centeredContentColumn"
import { verticalPadding } from "@lib/ui/css/verticalPadding"
import styled from "styled-components"
// 这个 `PageContainer` 组件用于创建一个居中内容的容器,并提供垂直间距。
export const PageContainer = styled.div`
${centeredContentColumn({
contentMaxWidth: 1600,
})}
${verticalPadding(80)}
`
切换到全屏模式
退出全屏模式
添加标签和标题接下来,我们需要创建一个合适的标题以提高在谷歌上的排名。我们将使用 <h1>
元素作为主标题,并通过包含 PageMetaTags
组件来设置页面的标题和描述元标签。
import { Text } from "@lib/ui/text"
import { PageMetaTags } from "@lib/next-ui/metadata/PageMetaTags"
export const NotesPageTitle = () => {
const 页面标题 = `吉他指板上的所有音符`
const 标题 = `吉他指板上的所有音符 | 互动式吉他指板图表`
const 描述 = `使用我们的互动图表探索吉他指板上的所有音符。涵盖15品和6弦上的每个音符,以提升您的吉他学习体验。`
return (
<>
<Text 居中对齐 粗度={800} 大小={32} 颜色="对比色" 类型="h1">
{页面标题}
</Text>
<PageMetaTags 标题={标题} 描述={描述} />
</>
)
}
全屏查看 退出全屏
借助语境和AI助手获得更好的结果我使用了ChatGPT来生成那段文案。然而,为了让AI真正理解产品,它需要一定的背景信息。因此,我总是维护一个名为context.md
的文件,里面保存了关于项目的全部原始信息。虽然一开始建立和维护这个文件会花一些时间,但从长远来看,这样可以节省不少时间和精力。你也就不用反复解释项目了,并且有了更好的背景信息,AI会提供更准确和相关的结果。
你将帮我处理与这个产品相关的任务。请在下方了解更多详情,并在理解产品后回复“是”。
此应用允许你在吉他指板上查看音阶和五声音阶。页面顶部有三个控件:
- 根音:选择音阶的根音(选项包括所有12个音符)。
- 音阶:选择音阶类型。选项包括大调、小调、布鲁斯、多利亚、弗里几亚、和声小调、旋律小调。
- 音阶类型:选择查看完整音阶还是仅查看五声音阶。
控件下方,你会看到带有所选音阶音符的指板。指板包含15个品格和6根琴弦。每个音符都被不同的颜色突出,并在圆圈内标注音符名称。
当你选择五声音阶时,应用会显示5个五声音阶模式。每个模式都在单独的指板上显示,从第一个到第五个模式依次显示。
URL格式为/[scaleType]/[rootNote]/[scale]
。例如,/pentatonic/e/minor
显示根音为E的小调五声音阶。
在首页/
,应用显示指板上的所有音符。
技术栈:应用使用的技术如下。应用代码包含在TypeScript单代码库中。它使用NextJS构建。该应用不使用服务器端渲染,而是依靠静态站点生成。
进入全屏模式/退出全屏模式
构建指板组件我们的NotesPage
内容非常直接:一个用来显示所有音符的指板。我们使用Fretboard
组件作为容器,并将音符传递给它作为子元素。
import { range } from "@lib/utils/array/range" // 导入一个生成范围内数字序列的函数
import { chromaticNotesNumber, isNaturalNote } from "@product/core/note" // 导入音阶音符数量和判断音符是否为自然音的函数
import { Fretboard } from "../guitar/fretboard/Fretboard" // 导入用于表示吉他指板的组件
import { stringsCount, tuning, visibleFrets } from "../guitar/config" // 导入吉他配置信息,包括琴弦数量、调弦和可见琴格数量
import { Note } from "../guitar/fretboard/Note" // 导入用于表示音符的组件
export const NotesPageContent = () => {
return (
<Fretboard>
{range(stringsCount).map((string) => { // 遍历每个琴弦
const openNote = tuning[string] // 获取开放音符
return range(visibleFrets + 1).map((index) => { // 遍历每个可见琴格
const note = (openNote + index) % chromaticNotesNumber // 计算音符
const fret = index === 0 ? null : index - 1 // 计算品丝位置
return (
<Note
key={`${string}-${index}`} // 生成唯一标识符
string={string} // 传递当前琴弦
fret={fret} // 传递当前品丝位置
value={note} // 传递当前音符
kind={isNaturalNote(note) ? '常规' : '副'} // 判断音符类型
/>
)
})
})}
</Fretboard>
)
}
进入全屏 退出全屏
Neck
组件我们用作琴颈的容器。它是一个具有固定高度和相对定位的 flexbox 行布局。
import { toSizeUnit } from "@lib/ui/css/toSizeUnit"
import styled from "styled-components"
import { fretboardConfig } from "./config"
import { getColor } from "@lib/ui/theme/getters"
import { range } from "@lib/utils/array/range"
import { String } from "./String"
import { Fret } from "./Fret"
import { getFretMarkers } from "@product/core/guitar/fretMarkers"
import { FretMarkerItem } from "./FretMarkerItem"
import { hStack } from "@lib/ui/css/stack"
import { stringsCount, visibleFrets } from "../../guitar/config"
import { ComponentWithChildrenProps } from "@lib/ui/props"
import { Nut } from "./Nut"
const Neck = styled.div`
height: ${toSizeUnit(fretboardConfig.height)};
position: relative;
${hStack()};
`
const OpenNotes = styled.div`
width: ${toSizeUnit(fretboardConfig.openNotesSectionWidth)};
`
const Frets = styled.div`
position: relative;
flex: 1;
background: ${getColor("foreground")};
`
export const Fretboard = ({ children }: ComponentWithChildrenProps) => {
return (
<Neck>
<OpenNotes />
<Nut />
<Frets>
{range(visibleFrets).map((index) => (
<Fret key={index} index={index} />
))}
{getFretMarkers(visibleFrets).map((value) => (
<FretMarkerItem key={value.index} value={value} />
))}
{range(stringsCount).map((index) => (
<String key={index} index={index} />
))}
{children}
</Frets>
</Neck>
)
}
琴颈部分代码如下:
const 琴颈 = styled.div`
height: ${toSizeUnit(fretboardConfig.height)};
position: relative;
${hStack()};
`
开放音符部分代码如下:
const 开放音符 = styled.div`
width: ${toSizeUnit(fretboardConfig.openNotesSectionWidth)};
`
品位部分代码如下:
const 品位 = styled.div`
position: relative;
flex: 1;
background: ${getColor("foreground")};
`
指板组件的代码如下:
export const 指板 = ({ children }: ComponentWithChildrenProps) => {
return (
<琴颈>
<开放音符 />
<琴枕 />
<品位>
{range(visibleFrets).map((index) => (
<Fret key={index} index={index} />
))}
{getFretMarkers(visibleFrets).map((value) => (
<FretMarkerItem key={value.index} value={value} />
)}
{range(stringsCount).map((index) => (
<琴弦 key={index} index={index} />
))}
{children}
</品位>
</琴颈>
)
}
全屏 / 进入 退出
调整琴颈和品位位置
其他的静态参数,如颈高,都保存在config.ts
文件这个单一的来源。
// 定义音符大小为36
const noteSize = 36;
// 定义音符位置偏移量为2
const noteFretOffset = 2;
// 导出琴颈配置
export const fretboardConfig = {
// 琴颈高度为240
height: 240,
// 品柱宽度为20
nutWidth: 20,
// 弦的偏移量为0.04
stringsOffset: 0.04,
// 音符大小
noteSize,
// 开放音符部分的宽度
openNotesSectionWidth: noteSize + noteFretOffset * 2,
// 音符位置偏移量
noteFretOffset,
// 最粗弦的宽度为8
thickestStringWidth: 8,
};
进入全屏模式,退出全屏模式
表示琴颈上的品位标记
Neck
组件的其第一个子组件是OpenNotes
组件,它作为存放打开笔记的占位符,具有一个固定宽度,等于笔记宽度加上偏移量。
接下来是 琴枕
组件,它也有固定的宽度,但包含了一个背景颜色以突出琴颈的开始。
import { toSizeUnit } from "@lib/ui/css/toSizeUnit"
import { getColor } from "@lib/ui/theme/getters"
import styled from "styled-components"
import { fretboardConfig } from "./config"
export const Nut = styled.div`
height: ${toSizeUnit(fretboardConfig.height)};
width: ${toSizeUnit(fretboardConfig.nutWidth)};
background: ${getColor("textShy")};
`
全屏 退出全屏
Frets
组件占据了剩余的空隙,背景色与页面背景形成对比,并采用相对定位,方便子组件进行绝对定位。
接下来,我们遍历可见的品位,并为每个品位渲染一个 Fret
组件。此类变量,如可见品位的数量和弦的数量,存储在一个单独的配置文件中。该文件还包含其他重要参数,例如总品位数、弦的调弦以及每根弦的粗细。
export const 琴弦数量 = 6
export const 可见品位数 = 15
export const 总品位数 = 22
// 调弦数组
export const 调弦 = [7, 2, 10, 5, 0, 7]
// 琴弦厚度数组
export const 琴弦厚度 = [0.1, 0.15, 0.25, 0.4, 0.7, 1]
切换到全屏模式 退出全屏
为了表示琴品位,我们只需要绘制一条1像素宽的线。为了将元素中心对齐,我们使用了来自RadzionKit库的PositionAbsolutelyCenterVertically
组件。
// 导入组件和布局相关的模块
import { ComponentWithIndexProps } from "@lib/ui/props"
import { PositionAbsolutelyCenterVertically } from "@lib/ui/layout/PositionAbsolutelyCenterVertically"
// 导入样式模块和相关函数
import styled from "styled-components"
import { getColor } from "@lib/ui/theme/getters"
import { toPercents } from "@lib/utils/toPercents"
import { getFretPosition } from "@product/core/guitar/getFretPosition"
import { totalFrets, visibleFrets } from "../../guitar/config"
// 定义容器样式
const Container = styled.div`
background: ${getColor("textShy")};
height: 100%;
width: 1px;
`
// 导出Fret组件,用于显示品柱位置
export const Fret = ({ index }: ComponentWithIndexProps) => {
return (
<PositionAbsolutelyCenterVertically
fullHeight
left={toPercents(
getFretPosition({
index,
visibleFrets,
totalFrets,
}).end,
)}
>
<Container key={index} />
</PositionAbsolutelyCenterVertically>
)
}
全屏模式 退出全屏
为了计算 left
位置值,我们使用了 getFretPosition
工具函数。该函数根据品位的索引和总品位数返回其起始和结束位置。为了使指板看起来更真实,我们确保随着指板向上移动,品位之间的距离逐渐变小。
import { Interval } from "@lib/utils/interval/Interval"
type Input = {
index: number
visibleFrets: number
totalFrets: number
}
export const getFretPosition = ({ index, visibleFrets, totalFrets }: Input): Interval => {
function fretPosition(n: number): number {
return 1 - 1 / Math.pow(2, n / 12)
}
const totalFretboardLength = fretPosition(totalFrets)
const startFretPos = fretPosition(0)
const endFretPos = fretPosition(visibleFrets)
const normalizedStartPos = startFretPos / totalFretboardLength
const normalizedEndPos = endFretPos / totalFretboardLength
const fretStartPos = fretPosition(index)
const normalizedFretStartPos = fretStartPos / totalFretboardLength
const normalizedStartPosition =
(normalizedFretStartPos - normalizedStartPos) /
(normalizedEndPos - normalizedStartPos)
const fretEndPos = fretPosition(index + 1)
const normalizedFretEndPos = fretEndPos / totalFretboardLength
const normalizedEndPosition =
(normalizedFretEndPos - normalizedStartPos) /
(normalizedEndPos - normalizedStartPos)
return {
start: normalizedStartPosition,
end: normalizedEndPosition,
}
}
全屏进入 退出全屏
为了增强真实感,我们会显示品丝标记。我们使用 PositionAbsolutelyCenterVertically
组件将标记定位在指板的适当位置,并使用 getFretPosition
来确定每个品格的开始和结束位置。由于品格位置用通用的 Interval
类型表示,我们可以轻松地使用 getIntervalCenter
工具函数计算每个区间的中心位置。
import { 带值属性的组件 } from "@lib/ui/props"
import { 琴码 } from "@product/core/guitar/fretMarkers"
import { 圆形 } from "@lib/ui/css/round"
import { 相同尺寸 } from "@lib/ui/css/sameDimensions"
import { 获取颜色 } from "@lib/ui/theme/getters"
import styled from "styled-components"
import { fretboardConfig } from "./config"
import { 垂直居中定位 } from "@lib/ui/layout/PositionAbsolutelyCenterVertically"
import { 转百分比 } from "@lib/utils/toPercents"
import { 匹配 } from "@lib/ui/base/Match"
import { 居中 } from "@lib/ui/layout/Center"
import { 垂直堆叠 } from "@lib/ui/css/stack"
import { 垂直内边距 } from "@lib/ui/css/verticalPadding"
import { 获取区间中心 } from "@lib/utils/interval/getIntervalCenter"
import { 获取品位位置 } from "@product/core/guitar/getFretPosition"
import { 可见品位, 总品位 } from "../../guitar/config"
const Dot = styled.div`
${圆形};
${相同尺寸(fretboardConfig.height * 0.12)};
background: ${获取颜色("textShy")};
`
const DoubleMarkerContainer = styled.div`
${垂直堆叠({
justifyContent: "space-between",
fullHeight: true,
})}
${垂直内边距(fretboardConfig.height * 0.08)};
`
export const FretMarkerItem = ({
value,
}: 带值属性的组件<琴码>) => {
return (
<垂直居中定位
fullHeight
left={转百分比(
获取区间中心(
获取品位位置({
index: value.index,
可见品位,
总品位,
}),
),
)}
>
<匹配
value={value.type}
单={() => (
<居中>
<Dot />
</居中>
)}
双={() => (
<DoubleMarkerContainer>
<Dot />
<Dot />
</DoubleMarkerContainer>
)}
/>
</垂直居中定位>
)
}
全屏模式 退出全屏
根据标记的种类,我们渲染一个或两个点。为了解决这种情况,我们使用了Match
组件(来自RadzionKit),该组件允许我们根据value
属性的不同类型条件性地渲染不同的组件。这种方法是传统switch语句的一个很好的替代。
为了决定哪些琴品应该显示标记,我们使用了 getFretMarkers
函数。该函数返回一个 FretMarker
对象数组,每个对象都包含了琴品索引和要显示的标记类型。
import { range } from "@lib/utils/array/range"
import { chromaticNotesNumber } from "../note"
// 品记类型数组
export const fretMarkerTypes = ["单", "双"] as const[]
export type FretMarkerType = (typeof fretMarkerTypes)[number]
// 品记类型
export type FretMarker = {
索引: number
类型: FretMarkerType
}
// 获取品记
export const getFretMarkers = (numberOfFrets: number): FretMarker[] => {
const 标记: FretMarker[] = []
range(numberOfFrets).forEach((index) => {
const 品数 = (index + 1) % chromaticNotesNumber
if ([3, 5, 7, 9, 12].includes(品数)) { // 如果品数在 [3, 5, 7, 9, 12] 之中
标记.push({ 索引: index, 类型: "单" })
} else if (品数 === 0) {
标记.push({ 索引: index, 类型: "双" })
}
})
return 标记
}
切换到全屏模式, 退出全屏
调整吉他弦和对应的音符
最后,我们来渲染吉他弦。对于垂直定位,我们使用 PositionAbsolutelyCenterHorizontally
组件。为了增强真实感,我们使用 repeating-linear-gradient
来创建一个类似于真实吉他弦纹理的图案。
import { ComponentWithIndexProps } from "@lib/ui/props"
import { getColor } from "@lib/ui/theme/getters"
import styled, { css } from "styled-components"
import { PositionAbsolutelyCenterHorizontally } from "@lib/ui/layout/PositionAbsolutelyCenterHorizontally"
import { toPercents } from "@lib/utils/toPercents"
import { getStringPosition } from "./utils/getStringPosition"
import { toSizeUnit } from "@lib/ui/css/toSizeUnit"
import { fretboardConfig } from "./config"
import { stringsThickness } from "../../guitar/config"
const Container = styled.div<{ isBassString: boolean }>`
background: ${({ isBassString }) =>
isBassString
? css`repeating-linear-gradient(135deg, ${getColor("background")}, ${getColor("background")} 1.5px, ${getColor("textSupporting")} 1.5px, ${getColor("textSupporting")} 3px)`
: css`
${getColor("textSupporting")}
`};
width: calc(100% + ${toSizeUnit(fretboardConfig.nutWidth)});
margin-left: ${toSizeUnit(-fretboardConfig.nutWidth)};
position: relative;
color: ${getColor("background")};
`
export const String = ({ index }: ComponentWithIndexProps) => {
const isBassString = index > 2
return (
<PositionAbsolutelyCenterHorizontally
top={toPercents(getStringPosition(index))}
fullWidth
>
<Container
isBassString={isBassString}
style={{
height: fretboardConfig.thickestStringWidth * stringsThickness[index],
}}
key={index}
/>
</PositionAbsolutelyCenterHorizontally>
)
}
全屏 → 退出全屏
实现可调整大小的页面功能随着 Fretboard
组件完成,我们回到 NotesPageContent
组件来显示实际的音符。对于每一根弦和每一个可见的品位,我们从开放弦的音名开始,通过增加品位索引来确定音符在十二平均律中的位置。我们还确定音符是自然音还是升降音。通过将 kind
属性设置为 secondary
,我们使升降音显得不那么突出。
管理缩放状态管理和更新URL
我们用数字表示每个音符:A
是 0,A#
是 1,以此类推。为了生成音符名称,我们使用小调音阶并对其进行迭代。对于模式中的每个步骤,如果步骤是 2,表示在这两个自然音之间有一个升音,所以我们就将其加入到数组中。为了判断一个音符是否是自然音,我们看它的名字长度是否为 1。
import { scalePatterns } from "../scale"
// 导出自然音名数组
export const naturalNotesNames = ["A", "B", "C", "D", "E", "F", "G"]
// 导出音阶名称数组,根据minor模式进行处理
export const chromaticNotesNames = scalePatterns.minor.reduce(
(acc, step, index) => {
const note = naturalNotesNames[index]
acc.push(note)
// 如果步长为2
if (step === 2) {
acc.push(`${note}#`)
}
return acc
},
[] as string[],
)
// 导出音阶名称数组的长度
export const chromaticNotesNumber = chromaticNotesNames.length
// 导出判断是否为自然音的函数,返回值为true表示是自然音
export const isNaturalNote = (note: number) =>
chromaticNotesNames[note].length === 1
进入全屏 退出全屏
我们的 Note
组件支持三种类型:regular
、secondary
和 primary
。primary
类型用于突出显示音阶的根音符。要定位一个音符,我们传递弦索引和品索引。如果 fret
是 null,则表示空弦。
/*
导入相关工具函数
*/
import { toPercents } from "@lib/utils/toPercents";
import { getStringPosition } from "./utils/getStringPosition";
import { getFretPosition } from "@product/core/guitar/getFretPosition";
import { toSizeUnit } from "@lib/ui/css/toSizeUnit";
import { fretboardConfig } from "./config";
import styled, { css, useTheme } from "styled-components";
import { round } from "@lib/ui/css/round";
import { sameDimensions } from "@lib/ui/css/sameDimensions";
import { PositionAbsolutelyByCenter } from "@lib/ui/layout/PositionAbsolutelyByCenter";
import { getColor } from "@lib/ui/theme/getters";
import {
ComponentWithKindProps,
ComponentWithValueProps,
StyledComponentWithColorProps,
} from "@lib/ui/props";
import { centerContent } from "@lib/ui/css/centerContent";
import { chromaticNotesNames } from "@product/core/note";
import { totalFrets, visibleFrets } from "../../guitar/config";
import { match } from "@lib/utils/match";
/*
定义音符类型
*/
type NoteKind = "regular" | "secondary" | "primary";
/*
定义音符属性类型
*/
type NoteProps = Partial<ComponentWithKindProps<NoteKind>> &
ComponentWithValueProps<number> & {
string: number;
fret: number | null;
};
/*
定义容器样式
*/
const Container = styled.div<
ComponentWithKindProps<NoteKind> & StyledComponentWithColorProps
>```
${round}
${sameDimensions(fretboardConfig.noteSize)}
border: 1px solid transparent;
${centerContent};
${({ kind, $color, theme: { colors } }) =>
match(kind, {
regular: () => css`
border-color: ${$color.toCssValue()};
background: ${getColor("background")};
color: ${getColor("contrast")};
`,
secondary: () => css`
background: ${getColor("foreground")};
border-color: ${getColor("mistExtra")};
color: ${getColor("textSupporting")};
`,
primary: () => css`
background: ${$color.toCssValue()};
color: ${$color
.getHighestContrast(colors.background, colors.text)
.toCssValue()};
font-weight: 600;
`,
})}
```;
/*
导出音符组件
*/
export const Note = ({ string, fret, kind = "regular", value }: NoteProps) => {
const top = toPercents(getStringPosition(string));
const {
colors: { getLabelColor },
} = useTheme();
const left = `calc(${
fret === null
? toSizeUnit(-fretboardConfig.nutWidth)
: toPercents(
getFretPosition({ totalFrets, visibleFrets, index: fret }).end,
)
} - ${toSizeUnit(fretboardConfig.noteSize / 2 + fretboardConfig.noteFretOffset)})`;
return (
<PositionAbsolutelyByCenter top={top} left={left}>
<Container $color={getLabelColor(value)} kind={kind}>
{chromaticNotesNames[value]}
</Container>
</PositionAbsolutelyByCenter>
);
};
点击全屏 点击退出全屏
就像处理其他琴颈元素一样,我们依赖配置文件中的常量来计算音符的位置。PositionAbsolutelyByCenter
组件帮助我们将音符的位置精确地定位于中心位置。
展示所有音符在指板上后,我们现在可以进入音阶页面。此页面采用动态路由,由三部分组成:音阶类型、根音和音阶名称。音阶类型可以是 scale
或 pentatonic
(音阶或五声音阶),根音是12个音符中的一个,音阶名称对应于预定义的音阶之一。
import { ScalePattern } from "./ScalePattern"
export const scales = [
"major",
"minor",
"blues",
"dorian",
"mixolydian",
"phrygian",
"harmonic-minor",
"melodic-minor",
] as const
export type Scale = (typeof scales)[number]
export const scalePatterns: Record<Scale, ScalePattern> = {
major: [2, 2, 1, 2, 2, 2, 1],
minor: [2, 1, 2, 2, 1, 2, 2],
blues: [3, 2, 1, 1, 3, 2],
dorian: [2, 1, 2, 2, 2, 1, 2],
mixolydian: [2, 2, 1, 2, 2, 1, 2],
phrygian: [1, 2, 2, 2, 1, 2, 2],
["harmonic-minor"]: [2, 1, 2, 2, 1, 3, 1],
["melodic-minor"]: [2, 1, 2, 2, 2, 2, 1],
}
export const scaleNames: Record<Scale, string> = {
major: "Major",
minor: "Minor",
blues: "Blues",
dorian: "Dorian",
mixolydian: "Mixolydian",
phrygian: "Phrygian",
["harmonic-minor"]: "Harmonic Minor",
["melodic-minor"]: "Melodic Minor",
}
export const pentatonicPatterns: Record<Scale, ScalePattern> = {
major: [2, 2, 3, 2, 3],
minor: [3, 2, 2, 3, 2],
blues: [3, 2, 1, 3, 2],
dorian: [2, 3, 2, 2, 3],
mixolydian: [2, 2, 3, 2, 3],
phrygian: [1, 3, 2, 3, 2],
["harmonic-minor"]: [2, 1, 3, 2, 3],
["melodic-minor"]: [2, 3, 2, 2, 3],
}
export const scaleTypes = ["scale", "pentatonic"] as const
export type ScaleType = (typeof scaleTypes)[number]
export const pentatonicNotesNumber = 5
切换到全屏模式, 退出全屏
我们将音阶表示为一系列步长,其中每一步代表两个音符之间的半音距离。大多数音阶(除了布鲁斯音阶外)有七个步长,而五声音阶则有五个步长。
为了管理所选配置,我们使用 ScaleState
类型。此状态被存储在 React 上下文中。通过使用 RadzionKit 提供的 getValueProviderSetup
工具,我们创建了提供者和钩子,从而能够无缝地访问状态。
import { getValueProviderSetup } from "@lib/ui/state/getValueProviderSetup"
import { Scale, ScaleType } from "@product/core/scale"
import { useRouter } from "next/router"
import { useCallback } from "react"
import { toUriNote } from "@product/core/note/uriNote"
export type ScaleState = {
scale: Scale
scaleType: ScaleType
rootNote: number
}
export const makeScalePath = ({ scaleType, scale, rootNote }: ScaleState) =>
`/${scaleType}/${toUriNote(rootNote)}/${scale}`
export const { useValue: useScale, provider: ScaleProvider } =
getValueProviderSetup<ScaleState>("Scale")
export const useChangeScale = () => {
const value = useScale()
const { push } = useRouter()
return useCallback(
(params: Partial<ScaleState>) => {
push(makeScalePath({ ...value, ...params }))
},
[push, value],
)
}
全屏模式 退出全屏
生成静态页面以增强 SEO 效果
当用户改变音阶类型、根音或音阶时,我们需要将用户重定向到新的URL。为此,我们使用useChangeScale
钩子。该钩子获取当前音阶状态和路由器实例。它返回一个回调函数,该函数根据新的音阶状态来更新URL。为了构建URL,我们使用了makeScalePath
实用工具。
为了让刻度页面的URL更易读,我们将数字音符转换为适合URI的格式。升记号被替换为-sharp
,并将音符转换成小写。要将URI音符转换回数字形式,我们使用fromUriNote
工具函数。
import { chromaticNotesNames, chromaticNotesNumber } from "."
export const toUriNote = (note: number) =>
chromaticNotesNames[note % chromaticNotesNumber]
.replace("#", "-sharp")
.toLowerCase()
export const fromUriNote = (uriNote: string) => {
const noteName = uriNote.replace("-sharp", "#").toUpperCase()
return chromaticNotesNames.findIndex((n) => n === noteName)
}
进入全屏/退出全屏
由于每个音阶都有自己的静态页面,我们需要生成它们。为了做到这一点,我们使用了 Next.js 提供的 getStaticPaths
和 getStaticProps
函数。getStaticPaths
函数通过组合音阶类型、根音和音阶来生成所有可能的路径。getStaticProps
函数从 URL 中提取音阶类型、根音和音阶,并将这些信息作为属性提供给页面。
导入 { GetStaticPaths, GetStaticProps } from "next"
导入 { Scale, scales, ScaleType, scaleTypes } from "@product/core/scale"
导入 { chromaticNotesNumber } from "@product/core/note"
导入 { toUriNote, fromUriNote } from "@product/core/note/uriNote"
导入 { ScalePage } from "../../../../scale/ScalePage"
导入 { range } from "@lib/utils/array/range"
导出默认 ScalePage
类型 Params = {
scaleType: 字符串
rootNote: 字符串
scale: 字符串
}
导出 const getStaticPaths: GetStaticPaths<Params> = async () => {
const paths = scaleTypes.flatMap((scaleType) =>
scales.flatMap((scale) =>
range(chromaticNotesNumber).flatMap((rootNote) => ({
params: {
scaleType,
rootNote: toUriNote(rootNote),
scale,
},
})),
),
)
返回 {
paths,
fallback: false,
}
}
导出 const getStaticProps: GetStaticProps = async ({ params }) => {
const { scaleType, rootNote, scale } = params as Params
const rootNoteNumber = fromUriNote(rootNote)
返回 {
props: {
value: {
scaleType: scaleType as ScaleType,
rootNote: rootNoteNumber,
scale: scale as Scale,
},
},
}
}
全屏模式,进入/退出
使用音阶模式ScalePage
组件接收缩放状态作为 props 并将其传递给 ScaleProvider
。这让子组件可以轻松访问缩放状态,而无需 prop drilling(prop 钻取)。
下面的代码定义了一个名为ScalePage的组件,该组件使用了多个自定义组件和状态提供者来展示不同的音阶信息。
ScalePage组件接收一个值参数,这个参数是ScaleState类型的,它定义了当前音阶的状态。此组件通过ScaleProvider提供状态给其子组件,如ScaleManager、ScalePageTitle和ScaleNotes等。VStack是一个垂直堆叠组件,它接受一个gap参数来定义组件间的间距。在ScalePage组件中,两个嵌套的VStack组件被用来布局各个子组件,其中第一个VStack的gap值为120,第二个VStack的gap值为60。
ScaleManager组件用于管理音阶的相关操作,ScalePageTitle组件显示音阶的标题,ScaleNotes组件展示音阶的音符信息。如果当前音阶类型是五声音阶,则会渲染PentatonicPatterns组件,该组件展示五声音阶的模式信息。
进入全屏 退出全屏
在页面顶部,我们提供了让用户选择要查看的音阶的控件。这些控件依次包括根音符(根音)、音阶设定和音阶类型。ScaleManager
组件把这些控件排列成一个灵活的行布局。
import { HStack } from "@lib/ui/css/stack"
// 导入 HStack 组件
import { ManageRootNote } from "./ManageRootNote"
import { ManageScale } from "./ManageScale"
import { ManageScaleType } from "./ManageScaleType"
// 规模管理器组件,用于管理根音符、音阶和音阶类型
export const ScaleManager = () => {
return (
<HStack alignItems="center" gap={16} fullWidth justifyContent="center">
<ManageRootNote />
<ManageScale />
<ManageScaleType />
</HStack>
)
}
全屏 退出全屏
为了显示根音和音阶的选择器,我们使用了来自 RadzionKit 的选择器 ExpandableSelector
。你可以在这里了解更多关于它的实现详情:here。
/scale-selector.png
import { range } from "@lib/utils/array/range"
import { ExpandableSelector } from "@lib/ui/select/ExpandableSelector"
import { chromaticNotesNames, chromaticNotesNumber } from "@product/core/note"
import { useChangeScale, useScale } from "../state/scale"
export const ManageRootNote = () => {
const { rootNote } = useScale()
const setValue = useChangeScale()
return (
<ExpandableSelector
value={rootNote}
onChange={(rootNote) => {
setValue({ rootNote })
}}
options={range(chromaticNotesNumber)}
getOptionKey={(index) => chromaticNotesNames[index]}
ariaLabel="无障碍标签='根音符'"
/>
)
}
进入全屏模式,切换回正常模式
为了在 scale 和 pentatonic 之间切换视图模式,我们使用来自 RadzionKit 的 GroupedRadioInput
组件来切换。
import { capitalizeFirstLetter } from "@lib/utils/capitalizeFirstLetter"
import { GroupedRadioInput } from "@lib/ui/inputs/GroupedRadioInput"
import { scaleTypes } from "@product/core/scale"
import { useChangeScale, useScale } from "../state/scale"
export const ManageScaleType = () => {
const { scaleType } = useScale()
const setValue = useChangeScale()
// 设置比例尺类型
return (
<GroupedRadioInput
options={scaleTypes}
renderOption={capitalizeFirstLetter}
value={scaleType}
onChange={(scaleType) => setValue({ scaleType })}
/>
)
}
全屏 退出全屏
接下来,我们将显示标题,该标题遵循与首页的标题相同的原则。然而,在这个页面上,文本会根据你选择的尺寸动态生成。
import { 文本组件 } from "@lib/ui/text"
import { useScale } from "./state/scale"
import { 音名 } from "@product/core/note"
import { 音阶名称 } from "@product/core/scale"
import { 首字母大写的 } from "@lib/utils/capitalizeFirstLetter"
import { 页面元标签 } from "@lib/next-ui/metadata/PageMetaTags"
export const 音阶页面标题 = () => {
const { scale, rootNote, scaleType } = useScale()
const noteName = 音名[rootNote]
const scaleName = 音阶名称[scale]
const scaleTypeName = 首字母大写的(scaleType)
const title = `${noteName} ${scaleName} ${scaleTypeName} 吉他练习`
const description = `学习如何在吉他上演奏 ${noteName} ${scaleName} ${scaleTypeName}。探索指板上的音符,并掌握五声音阶和全音阶的模式。`
return (
<>
<文本组件 水平居中 字体权重={800} 大小={32} 颜色="对比色" as="h1">
{title}
</文本组件>
<页面元标签 标题={title} 描述={description} />
</>
)
}
全屏查看,退出全屏
根据所选的音阶类型,我们从 scalePatterns
或 pentatonicPatterns
记录中找到合适的模式。然后,我们使用 getScaleNotes
辅助函数来确定音阶中的音符。
import { getLastItem } from "@lib/utils/array/getLastItem"
import { chromaticNotesNumber } from "../note"
import { ScalePattern } from "./ScalePattern"
type Input = {
rootNote: number
pattern: ScalePattern
}
export const getScaleNotes = ({ rootNote, pattern }: Input): number[] =>
pattern.reduce(
(notes, step) => [
...notes,
(getLastItem(notes) + step) % chromaticNotesNumber,
],
[rootNote],
)
全屏, 退出全屏
接下来,我们遍历字符串和品位,只渲染属于该音阶的音符。如果该音符是根音符,则传递primary
类型来突出它。
import { range } from "@lib/utils/array/range"
import { chromaticNotesNumber } from "@product/core/note"
import { Fretboard } from "../guitar/fretboard/Fretboard"
import { stringsCount, tuning, visibleFrets } from "../guitar/config"
import { Note } from "../guitar/fretboard/Note"
import { scalePatterns, pentatonicPatterns } from "@product/core/scale"
import { getScaleNotes } from "@product/core/scale/getScaleNotes"
import { useScale } from "./state/scale"
export const ScaleNotes = () => {
const { scale, rootNote, scaleType } = useScale()
const pattern = (
scaleType === "pentatonic" ? pentatonicPatterns : scalePatterns
)[scale]
const notes = getScaleNotes({
pattern,
rootNote,
})
// 获取音符的音阶
// 获取音阶上的音符
const notes = getScaleNotes({
pattern,
rootNote,
})
// 使用音阶的状态
const { scale, rootNote, scaleType } = useScale()
return (
<Fretboard>
{range(stringsCount).map((string) => {
const openNote = tuning[string]
return range(visibleFrets + 1).map((index) => {
const note = (openNote + index) % chromaticNotesNumber
const fret = index === 0 ? null : index - 1
if (notes.includes(note)) {
// 显示音符的关键属性
// string: 弦编号
// fret: 品数
// value: 音符值
// kind: 音符类型
return (
<Note
key={`${string}-${index}`}
string={string}
fret={fret}
value={note}
kind={rootNote === note ? "primary" : "regular"}
/>
)
}
return null
})
})}
</Fretboard>
)
}
点击这里全屏观看 点击这里退出全屏
显示五声音阶的模式在展示五声音阶时,我们也会呈现五种五声调式,这样做比较常见。
import { range } from "@lib/utils/array/range"
import { PentatonicPattern } from "./PentatonicPattern"
import { pentatonicNotesNumber, scaleNames } from "@product/core/scale"
import { Text } from "@lib/ui/text"
import { chromaticNotesNames } from "@product/core/note"
import { useScale } from "../state/scale"
import { VStack } from "@lib/ui/css/stack"
export const 五声音阶模式 = () => {
const { rootNote, scale } = useScale()
const noteName = chromaticNotesNames[rootNote]
const scaleName = scaleNames[scale]
const title = `${noteName} ${scaleName} 五声音阶模式`
return (
<VStack gap={60}>
<Text 水平居中 weight={800} size={32} color="对比色" as="h2">
{title}
</Text>
{range(pentatonicNotesNumber).map((index) => (
<PentatonicPattern key={index} index={index} />
))}
</VStack>
)
}
全屏模式 退出全屏
我们在页面上专门创建了一个带有 <h2>
标题的独立部分。在这一部分,我们通过使用 PentatonicPattern
组件来列出五个音阶模式。
import { ComponentWithIndexProps } from "@lib/ui/props"
import { useScale } from "../state/scale"
import { pentatonicPatterns } from "@product/core/scale"
import { getScaleNotes } from "@product/core/scale/getScaleNotes"
import { range } from "@lib/utils/array/range"
import { chromaticNotesNumber } from "@product/core/note"
import { stringsCount, tuning, visibleFrets } from "../../guitar/config"
import { Fretboard } from "../../guitar/fretboard/Fretboard"
import { Note } from "../../guitar/fretboard/Note"
import { withoutUndefined } from "@lib/utils/array/withoutUndefined"
import { VStack } from "@lib/ui/css/stack"
import { Text } from "@lib/ui/text"
export const PentatonicPattern = ({ index }: ComponentWithIndexProps) => {
const { scale, rootNote } = useScale()
const pattern = pentatonicPatterns[scale]
const notes = getScaleNotes({
pattern,
rootNote,
})
const title = `第 ${index + 1} 个五声音阶模式`
return (
<VStack gap={24}>
<Text centerHorizontally={true} color="contrast" as="h3" weight="600" size={16}>
{title}
</Text>
<Fretboard>
{range(stringsCount).map((string) => {
const openNote = tuning[string]
const stringNotes = withoutUndefined(
range(visibleFrets + 1).map((index) => {
const note = (openNote + index) % chromaticNotesNumber
const fret = index === 0 ? null : index - 1
if (!notes.includes(note)) return
return { note, fret }
}),
).slice(index, index + 2)
return stringNotes.map(({ note, fret }) => {
return (
<Note
key={`${string}-${index}`}
string={string}
fret={fret}
value={note}
kind={rootNote === note ? "主音" : "普通音"}
/>
)
})
})}
</Fretboard>
</VStack>
)
}
全屏模式 退出全屏
这种方法类似于全尺寸的方法,但这次我们每个弦只渲染两个音符,即,每次迭代时将模式向右移动一个音符。这使我们能够在该部分的各个独立琴颈上显示所有五个模式。
这个应用让吉他手可以轻松查看音阶图,探索模式和结构,并更好地理解琴颈。我们利用React、TypeScript和Next.js,创建了一个既动态又适合搜索引擎的工具,它既能帮助你学习,又能陪你练习。希望你玩得开心!
共同学习,写下你的评论
评论加载中...
作者其他优质文章