0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

TypeScriptでジャズピアノが弾けるようになった(らいいな)

Posted at

作ったもの

ジャズのコードやコード進行パターンを練習する初学者をサポートするWebアプリを作りました。
モノはこちらです。

スクショ

jazpi.gif

作った人

  • 経験20年超の社内SE
  • 最初の7年ぐらいはネットワークエンジニア
  • 会社が無くなるのをきっかけに転職
  • 「ネットワークエンジニアが欲しかったんです!」という会社に採用してもらったが、1年ほど経った頃に「やっぱり社内SEやってくれる?」と言われる
  • 協力会社に力不足なところがあったので、自分でも開発するようになる(フロント&バックエンド)
  • 今に至る

なんで作ったか

  • TypeScriptの力試し
    • 仕事でもTypeScriptをよく使うようになったものの、業務アプリばかりやっていると実装がパターン化されてきて頭を使うことが減ってきたので。
  • 独学でピアノを始めたので
    • コロナの給付金で電子ピアノ買った。
  • 12キーで覚えるのが大変だったから
    • ジャズは大抵何でも「12キーで弾けるようになれ」というのが基本らしいです。

中身の紹介

どんなふうに実装してるかとか、工夫した点を紹介します。

svg-notes -- 音楽理論+SVG描画ライブラリ

”音楽ドメイン”とSVGの描画を担うライブラリを作成しました。
ディレクトリ構造はざっくりこんな感じ

svg-notes
├── src
│   ├── components
│   │   ├── music
│   │   │   ├── domain
│   │   │   │   ├── codes
│   │   │   └── index.ts
│   │   └── svg-notes
│   │       ├── composables
│   │       ├── domain
│   │       ├── view
│   │       └── index.ts
│   ├── index.ts
│   ├── main.ts

何はなくとも音名が無いと…ということでこんな感じに。

export type Tone = {
  toneName: ToneName,
  type: ToneType,
  displayText: DisplayTextSelector,
  match: (tone: ToneName) => boolean,
  next: () => Tone,
  prev: () => Tone
}

const toneNames = [
  'C' , 'C#' , 'DB' , 'D' , 'D#' , 'EB' , 'E' , 'F' , 'F#' , 'GB' , 'G' , 'G#'
  , 'AB' , 'A' , 'A#' , 'BB' , 'B'
] as const

export type ToneName = typeof toneNames[number]

export const isToneName = (tone: unknown): tone is ToneName => {
  return toneNames.some(t => t === tone)
}

export type ToneType = 'white' | 'black'

Toneの連結リストを構築して、Find系の操作を担うクラスも実装したり。
そうするとコード、たとえばマイナー7th(とその親戚コード)なんかはこんな感じで定義できるようになりました。

// 省略
type LocalCodeName = typeof codeNames[number]
type LocalCodeDefinitions = { [key in LocalCodeName]: Code}

const definitions: LocalCodeDefinitions = {
  'minor-seventh-flat-five-a': {
    name: 'minor-seventh-flat-five-a',
    displayText: {
      base: '-7',
      modular: '(♭5)'
    },
    intervals: ['3B', '5B', '7B', '9'],
    marks: ['♭3', '♭5', '♭7', '9'],
  },
  'minor-seventh-b': {
    name: 'minor-seventh-b',
    displayText: {
      base: '-7',
      modular: ''
    },
    intervals: ['7B', '9', '3B', '5'],
    marks: ['♭7', '9', '♭3', '5'],
  },
// 省略

次に、鍵盤のSVG画像を動的に組み立てるエンジン部分です。

エンジンの仕様としては、描画したい音名(1つ以上)の文字列を入力として受け取ります。リッチに字句解析や構文解析をするほどではないので split() 等で頑張りました。

テストはこんな感じ。

describe('基本的なプログラムのロード', () => {
  const engine = new KeyboardEngine
  const _notes = (program: string) => {
    engine.load(program)
    return engine.notes
  }
  
  // 省略
  
  it('C, D, E', () => {
    const notes = _notes('C, D, E')
    const toneNames = notes.map(n => n.tone.toneName)

    expect(toneNames).toEqual(['A', 'BB', 'B', 'C', 'DB', 'D', 'EB', 'E', 'F', 'GB', 'G'])
  })

View部分。エンジンが解析した音名のシーケンスを描画します。

<template>
  <svg :viewBox="viewBox">
      <template v-for="(note, index) in whiteKeys" :key="index">
        <Key :key-value="note" :index="index" :white-key-size="{ height, width: whiteKeyWidth}" />
      </template>
      <template v-for="(note, index) in blackKeys" :key="index">
        <Key :key-value="note" :index="index" :white-key-size="{ height, width: whiteKeyWidth}" :startsOnEorB="isStartsOnEorB" />
      </template>
  </svg>
</template>

jazpi -- Webアプリ

Webアプリ部分の実装です。

jazpi
├── public
├── src
│   ├── components
│   │   ├── courses
│   │   │   ├── composables
│   │   │   ├── domain
│   │   │   └── view
│   │   ├── jazpi
│   │   │   └── view
│   │   ├── shared
│   │   │   ├── composables
│   │   │   ├── domain
│   │   │   └── view
│   │   ├── workouts
│   │   │   ├── composables
│   │   │   ├── domain
│   │   │   └── view
│   │   └── models.ts
│   ├── courses
│   ├── i18n
│   ├── layouts
│   ├── pages
│   ├── router
│   ├── App.vue
│   ├── env.d.ts
│   ├── quasar.d.ts
│   └── shims-vue.d.ts
├── src-capacitor

当初はAndroidアプリにする予定もあったのでフレームワークはQuasarを採用しました。

音楽的な部分とSVG関連はsvg-notesで実装済みなので、アプリ側は描画したいコード進行を指定するだけです。

const categoryId: CourseCategoryId = 'two-five-one'

const courses: Course[] = [
  {
    id: 'two-five-one-a',
    categoryId,
    locale: {
        // 省略
      }
    },
    paddingLeft: (keyTone) => {
      const tone = keyTone.next().next()
      return tone.type === 'black' ? tone.prev().toneName : tone.toneName
    },
    progressionElements: [
      {
        interval: '2',
        codeName: 'minor-seventh-a'
      },
      {
        interval: '5',
        codeName: 'dominant-13-9-alter'
      },
      {
        interval: '1',
        codeName: 'major-seventh-a'
      },
    ]
  },

練習中に主に表示する部分のViewはこんな感じ。Vueは簡潔に書けてよいですね。

<template>
  <div>
    <!-- キー -->
    <jazpi-key :tone="tone" />
    <!-- トーナリティ -->
    <jazpi-tonality
      :progressions="progressions"
      class="q-mt-xs"
    />
    <!-- コード -->
    <jazpi-code
      :progressions="progressions"
      class="q-mt-xs"
    />
    <!-- 鍵盤 -->
    <jazpi-keyboard
      :key-tone="tone"
      :progressions="progressions"
      class="q-mt-xs"
    />
  </div>
</template>

<script setup lang="ts">
import JazpiCode from 'src/components/jazpi/view/JazpiCode.vue'
import JazpiKey from 'src/components/jazpi/view/JazpiKey.vue'
import JazpiKeyboard from 'src/components/jazpi/view/JazpiKeyboard.vue'
import JazpiTonality from 'src/components/jazpi/view/JazpiTonality.vue'
import { Workout } from '../domain/workout'

interface Props {
  workout: Workout
}
const props = defineProps<Props>()
const tone = computed(() => props.workout.keyTone)
const progressions = computed(() => props.workout.progressions)
</script>

練習の設定として、12キーをどういう順番で生成するかを指定するオプションがあるのですが、それが関係するswitch分ではnever型を利用して実装漏れを防いだり。

const generateKeysByWorkoutOption = (option: WorkoutOption): Tone[] => {
  const sequence = option.sequence
  switch (sequence.type) {
    case '4degrees': return generateFromFifthCircle(sequence.startFrom, '4')
    case '5degrees': return generateFromFifthCircle(sequence.startFrom, '5')
    case '4plus4degrees': return generate4Plus4(sequence.startFrom)
    case 'random': return generateRandom()
    case 'specified': return sequence.keyTones
    default:
      const _exhaustiveCheck: never = sequence
      return _exhaustiveCheck
  }
}

作ってみた感想

  • ボトムアップで作っていったので楽だった。
    • TypeScriptで「〇〇はこういう型だ(型になるはずだ)」と宣言的にコツコツ部品を積み上げていったらいつのまにかアプリも出来ていたという印象。
    • コツコツ行くことができずに一度にたくさんの型やcomposableの定義が必要になった時は「これは設計がまずいのかもしれん」と考え直すきっかけにできた。
  • Vue3は良い
    • Composition APIとコンポーザブルな構成が取れるおかげで前述の積み上げがやりやすい。
    • Vue2時代は、パッと作れるけどそこから手直ししながら~という作業に若干手間がかかってた気がします。

おわり

業務アプリではないものを作るとなるとメンタルモデルも色々変わったりして新鮮な気持ちで実装することができました。また何か作ったら記事にしたいと思います。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?