为了账号安全,请及时绑定邮箱和手机立即绑定

用React、TypeScript和Next.js打造吉他和弦可视化应用

标签:
React Typescript

🐙 GitHub | 🎮 试玩

简介:下面是一些关于...的内容

在这篇文章里,我们将使用React、TypeScript和NextJS为吉他手创建一个应用,该应用可以在指板上可视化音阶。你可以在这里查看最终结果here,也可以在这里查看代码库here。首先,我们将使用RadzionKit这个启动模板,它提供了一系列组件和工具,旨在简化React应用程序的开发,提高工作效率。

E 小调音阶在吉他指板上的图示

动力与灵感

我一直都有吉他,经常想出一些吉他独奏片段或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)中的 centeredContentColumnverticalPadding 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 组件支持三种类型:regularsecondaryprimaryprimary 类型用于突出显示音阶的根音符。要定位一个音符,我们传递弦索引和品索引。如果 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 组件帮助我们将音符的位置精确地定位于中心位置。

展示所有音符在指板上后,我们现在可以进入音阶页面。此页面采用动态路由,由三部分组成:音阶类型、根音和音阶名称。音阶类型可以是 scalepentatonic(音阶或五声音阶),根音是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 提供的 getStaticPathsgetStaticProps 函数。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 之间切换视图模式,我们使用来自 RadzionKitGroupedRadioInput 组件来切换。

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} />
        </>
      )
    }

全屏查看,退出全屏

根据所选的音阶类型,我们从 scalePatternspentatonicPatterns 记录中找到合适的模式。然后,我们使用 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,创建了一个既动态又适合搜索引擎的工具,它既能帮助你学习,又能陪你练习。希望你玩得开心!

点击查看更多内容
TA 点赞

若觉得本文不错,就分享一下吧!

评论

作者其他优质文章

正在加载中
  • 推荐
  • 评论
  • 收藏
  • 共同学习,写下你的评论
感谢您的支持,我会继续努力的~
扫码打赏,你说多少就多少
赞赏金额会直接到老师账户
支付方式
打开微信扫一扫,即可进行扫码打赏哦
今天注册有机会得

100积分直接送

付费专栏免费学

大额优惠券免费领

立即参与 放弃机会
微信客服

购课补贴
联系客服咨询优惠详情

帮助反馈 APP下载

慕课网APP
您的移动学习伙伴

公众号

扫描二维码
关注慕课网微信公众号

举报

0/150
提交
取消