69
53

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【初心者完全版】0からReact開発して基礎をマスターできる最強チュートリアル 音楽生成アプリ編【図解解説】

Posted at

IMG_6403.png

はじめに

こんにちは、Watanabe jinです。
ReactがAIも相まって選択されるケースが増えて学びたいと思っている人も多いかと思います。

私も過去にたくさんのReact教材をやりましたが、本当にやってよかったと思える教材はほぼありませんでした。
最初に選ぶ教材によってはReactの学習スピードは圧倒的に変わりますし、Reactの教材はJavaScriptの非同期処理やmapなどを踏まえて説明する必要があります。

このチュートリアルはそんな駆け出しエンジニアだった私に「これ1本だけやっておけば基礎は終わり」と言えるような完全版を目指して作成しました。JavaScriptがなんとなくという方でも最後まで絶対走りきれるように丁寧に解説しています。

最後までチュートリアルを行うとSpotifyのようなアプリが完成します。

名称未設定のデザイン (5).gif

動画教材も用意しています

こちらの教材にはより詳しく解説した動画もセットでご用意しています。
テキスト教材でわからない細かい箇所があれば動画も活用ください。

この記事の対象者

  • 最速でReactを学びたい
  • プログラミング0からReactをやりたい
  • アウトプット中心で学びたい
  • しっかりと実力をつけたい
  • モダンな開発を学びたい

HTML/CSSがわかれば2時間で最後までできるように設定しています

1. Reactの環境構築

このチャプターで学べること

  • React開発環境の基本設定
  • Reactの基本的な動作原理
  • 開発効率を高める仕組み

Reactの環境構築について解説していきます。
ここで学んでほしいのは手順通りに環境構築をして再現性をもってReactを始められるようにすることです。

1. 生のReactコードは直接実行できない

image.png

ReactではJSXという特殊な記法を使っているため、ブラウザではそのまま解釈(理解)することができません。

JSXはJavaScriptXMLの略称でHTMLをJavaScriptコードの中で直接記述できるシンタックスシュガー(ある構文を別の記法で記述できるようにしたもの)です。

function Greeting() {
  return <div>こんにちは世界!</div>
}

このように書いてもブラウザでは認識することができません。
これはJavaScriptをわかりやすく書くためにJSXという直感的に書ける記法を用いて書いています。

実際にはJSXが内部でコンパイルされてJavaScriptに変換されています。

function Greeting() {
  return React.createElement(
    'div',
    null,
    'こんにちは、世界!'
  );
}

JSXは最終的にReact.createElementの呼び出しに変換されます。
しかしこれでは直感的に開発するのが難しいのでJSXというわかりやすい形で書いています。(もちろんこの形式でjsファイルに書いてもReactは利用可能です)

このJSXからJavaScriptに変換してくれるのがViteです。
このように変換してくれるツールをビルドツールと呼びます。

JSXはJavaScriptを利用する場合の形式で、本チュートリアルはTypeScriptを利用するため TSXで行います。拡張子がhoge.jsxでなくhoge.tsxと変わるくらいなので、名前が違うということだけ覚えておけば大丈夫です。

2. 高速な開発環境の提供

Viteを利用することでコードを変更したときに、画面をリフレッシュすることなく、変更が即座に反映させることができます。

例えば画面内の文字を変更した時にViteを利用することで即座に変更をビルド(JSXからJavaScriptの変換する)をしてくれるので開発をスムーズに行うことができます。

またビルドツールにはWebpackなど色々ありますが、Viteはその中でも特に高速なツールです。

そんなReactの開発には欠かせないViteを使った開発環境を簡単に用意できるのでやっていきましょう。

まずはNode.jsがインストールするところからチェックしていきます。

$ node -v
v24.3.0

Node.jsがインストールできていない人は以下のサイトからご自身のOSにあったものをダウンロードしてください。

Node.jsはあなたのPCでJavaScriptを実行するための環境を用意してくれます。
それでは公式ドキュメントをみながらViteでReactの環境を構築してみましょう。

npm create vite@latest
Need to install the following packages:
create-vite@6.5.0
Ok to proceed? (y) y
│
◇  Project name:
│  music-creator-app
│
◇  Select a framework:
│  React
│
◇  Select a variant:
│  TypeScript
│
◇  Scaffolding project in /home/jinwatanabe/workspace/tmp/movie-app...
│
└  Done. Now run:

途中で選択がでるので「y」->「music-creator-app」->「React」->「TypeScript」で答えます。
今回は初心者向けではありますがTypeScriptの選択は今の時代必須になっているので慣れていくためにも利用していきます。ここも詳しく解説していきます。

プロジェクトができたらViteサーバーを起動してみましょう

cd music-creator-app
npm install
npm run dev

 VITE v6.3.5  ready in 115 ms

  ➜  Local:   http://localhost:5173/
  ➜  Network: use --host to expose
  ➜  press h + enter to show help

するとサーバーが起動します。
http://localhost:5173を開くと以下の画面が表示されれば起動できています。

image.png

npmというコマンドがでてきたので解説しておきます。

npm install

このコマンドはプロジェクトに必要なすべてのパッケージ(部品)をインターネットからダウンロードしてインストールします。パッケージのリストはディレクトリにあるpackage.jsonに書かれています。

package.json
{
  "name": "movie-app",
  "private": true,
  "version": "0.0.0",
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "tsc -b && vite build",
    "lint": "eslint .",
    "preview": "vite preview"
  },
  "dependencies": {
    "react": "^19.1.0",
    "react-dom": "^19.1.0"
  },
  "devDependencies": {
    "@eslint/js": "^9.25.0",
    "@types/react": "^19.1.2",
    "@types/react-dom": "^19.1.2",
    "@vitejs/plugin-react": "^4.4.1",
    "eslint": "^9.25.0",
    "eslint-plugin-react-hooks": "^5.2.0",
    "eslint-plugin-react-refresh": "^0.4.19",
    "globals": "^16.0.0",
    "typescript": "~5.8.3",
    "typescript-eslint": "^8.30.1",
    "vite": "^6.3.5"
  }
}

ここのdependenciesdevDependenciesにかかれているものをダウンロードして、node_modulesというディレクトリにいれています。試しにnode_modulesをみると以下のようにたくさんのディレクトリがあります。

image.png

このようにReact開発に必要なものを事前に定義してダウンロードできるので、開発に必要な部品をわざわざ1つ1つダウンロードして環境を作る必要がなく誰でも同じ環境がinstallコマンドで行えます。

npm run dev

このコマンドは、開発用のローカルサーバーを起動して、アプリケーションを実行します。
またサーバーが起動するとコードの変更を監視し、変更があれば即座に反映するホットリロードが行えます。これはViteで起動しているメリットです。

試しにコードを変更してみましょう。(ここではよくわからなくても大丈夫です)

src/App.tsxを以下に変更してください

src/App.tsx
import './App.css'

function App() {

  return (
    <div>
      <h1>Hello World</h1>
    </div>
  )
}

export default App

するとサーバーがすぐに更新をしてログを出してくれます。

7:49:20 PM [vite] (client) hmr update /src/App.tsx
7:49:26 PM [vite] (client) hmr update /src/App.tsx (x2)
7:49:34 PM [vite] (client) hmr update /src/App.tsx (x3)
7:49:36 PM [vite] (client) hmr update /src/App.tsx (x4)

そして画面をみるとコード変更して保存した瞬間に反映されています。

image.png

この速さがViteでReactを開発するメリットなのです!

2. JSXを理解する

このチャプターで学べること

  • JSXの書き方の理解
  • JavaScriptのmapの考え方
  • カーリーブレス

ここから本格的にReact開発をしていきます。
まずはReactをどのように書くのかを実際にみていきましょう。

まずはsrc/App.tsxを開きましょう。

App.tsx
import './App.css'

function App() {

  return (
    <div>
      <h1>Hello World</h1>
    </div>
  )
}

export default App

tsxは以下の構成で書くことができます。

function App() {
  // JavaScriptのコードを書く
  
  return (
   /* HTMLで画面の見た目部分を書く */
  )
}

export default App

ざっくりこれくらいの認識で進めて行けば問題はありません。

例えば先程の修正はHTMLでHello Worldを表示するためにreturnの中に書いたものでした

  return (
    <div>
      <h1>Hello World</h1>
    </div>
  )

では試しにJavaScriptのコードを書いてみます。アラートを実装してみましょう

src/App.tsx
import './App.css'

function App() {

  alert('JavaScriptを実行')

  return (
    <div>
      <h1>Hello World</h1>
    </div>
  )
}

export default App

http://localhost:5173を開くとアラートが表示されました
JavaScriptが内部で実行されたあとに、画面表示の処理が行われます。(2回アラートがでるのはReactの仕様なので一旦気にしないで進めてください)

image.png

import './App.css'

このコードはApp.tsxと同じ階層にあるApp.cssを使えるようにする設定です。(CSSを設定するファイルです)
試しに以下の内容にしてみましょう

src/App.css
h1 {
  color: red;
}

アラートは邪魔なので消しておきます。

src/App.tsx
import './App.css'

function App() {
  return (
    <div>
      <h1>Hello World</h1>
    </div>
  )
}

export default App

image.png

画面を開くとh1要素が赤文字になりました。しっかりCSSファイルが読み込まれていることがわかります。

※ CSSが適応されているのを確認したらCSSをもとに戻してこの先を進めてください

image.png

3. スタイリングライブラリを導入してみよう

このチャプターで学べること

  • TailwindCSSとはなにか?
  • Viteプロジェクトへの導入

このままCSSファイルを書いても良いですが、スタイリングライブラリであるtailwindCSSを導入することで楽にスタイリングができます

TailwindCSSは事前に用意された小さなクラス名を組み合わせてスタイリングを行うCSSフレームワークです。従来のCSSのように独自のクラス名を考える必要がなく、事前に用意されたクラス名を組み合わせることで効率的にスタイリングができます。

例えばこのようなスタイルを当てようとしたときに

.title {
  font-size: 24px;
  font-weight: bold;
  color: #1f2937;
  margin-bottom: 16px;
}

TailwindCSSを使えばクラスを1つつけるだけで同じスタイルを当てられます

<h1 className="text-2xl font-bold text-gray-800 mb-4">音楽生成アプリ</h1>

ここでJSXでクラスを当てるときにはclassNameという属性を利用します。
TailwindCSSに関してはドキュメントに詳しくクラス名とスタイルがかかれています。

例えばtext-2xlなら

font-sizeに設定が書かれています。

image.png

今回はスタイルに関してはチュートリアルの本質でないため、詳しくは解説しないので気になるものは各自調べてください。

それではTailwindCSSを導入してみましょう。
公式ドキュメント通りに設定していくだけです。

npm install tailwindcss @tailwindcss/vite

vite.config.tsにTawilwindCSSの設定をします。

vite.config.ts
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";

// https://vite.dev/config/
export default defineConfig({
  plugins: [react(), tailwindcss()],
});

次にindx.cssにスタイルの読み込みをします。

src/index.css
@import "tailwindcss";

App.cssはすべてのスタイルを消しておきましょう

image.png

今回はプロジェクト全体でTailwindCSSを利用したいのでindex.cssに設定を追加しました。
App.cssに追加した場合はApp.cssを読み込んでいるJSXのみでしか利用できません。

image.png

Reactではmain.tsxというファイルが根本にあってそこから色々なページを作成していきます。
この根本では

import './index.css`

とすべてで利用できるCSSとしてindex.cssを読み込んでいます。
そしてその中でAppコンポーネントを描画しています。

createRoot(document.getElementById('root')!).render(
  <StrictMode>
    <App /> {/* ここでAppを表示 */}
  </StrictMode>,
)

このAppこそが先程触っていたコードです。

src/App.tsx
import "./App.css";

function App() {
  return (
    <div>
      <h1>Hello World</h1>
    </div>
  );
}

export default App;

ここでコンポーネントという概念を解説します。

image.png

コンポーネントとはReactのUI(ユーザーインターフェース)を構築するパーツのようなもので、レゴブロックのように組み合わせることで1つのページを作成していきます。今回のAppコンポーネントも大きな1つのパーツのようなものです。

image.png

コンポーネントの作り方には「関数コンポーネント」「クラスコンポーネント」がありますが、今は関数コンポーネントが主流となっています。Appコンポーネントも同じように書いています。

コンポーネントは再利用が可能なので、Greetingのように何度も利用することで「こんにちは、React!」を簡単に何回も表示ができます。

それではTailwindCSSの導入ができたかをチェックしましょう

src/App.tsx
import "./App.css";

function App() {
  return (
    <div>
      <h1 className="text-3xl font-bold underline">音楽生成アプリ</h1>
    </div>
  );
}

export default App;

image.png

このように表示されていればスタイルが当てられたことが確認できます。

4. Shadcn/UIでおしゃれなUIを簡単に実装しよう

このチャプターで学べること

  • Shadcn/UIとは何か、なぜ使うのか
  • Shadcn/UIの導入方法
  • ボタンコンポーネントの実装

TailwindCSSでスタイリングの基本は整いましたが、実際のアプリ開発ではボタンカードモーダルなどの複雑なコンポーネントを一から作るのは時間がかかります。

そこで登場するのがShadcn/UIです。
Shadcn/UIは 「コピペで使える高品質なReactコンポーネント集」 です。

従来の開発の大変さ

例えば、音楽アプリでよく見る「再生ボタン」を作ろうとすると

<button className="bg-green-500 hover:bg-green-600 text-white font-bold py-2 px-4 rounded-full shadow-lg transition-all duration-200 hover:shadow-xl hover:scale-105 active:scale-95 flex items-center gap-2">
  <PlayIcon className="w-4 h-4" />
  再生
</button>

これだけでも長いクラス名になり、さらに

  • ホバー時のアニメーション
  • クリック時の効果
  • アイコンの配置
  • アクセシビリティ対応

などを考えると一つのボタンを作るだけで相当な時間がかかってしまいます。

Shadcn/UIを使った場合

import { Button } from "@/components/ui/button"

<Button variant="default" size="lg">
  再生
</Button>

たったこれだけで、おしゃれなボタンが完成します。

Shadcn/UIは先程導入したTailwindCSSが裏側で利用されているため、TailwindCSSを事前に導入する必要があります。(今回はすでに導入しているのでOK)

Shadcn/UIの導入方法

それでは実際に公式ドキュメントをみながら導入ていきます。

tsconfig.json
{
  "files": [],
  "references": [
    { "path": "./tsconfig.app.json" },
    { "path": "./tsconfig.node.json" }
  ],
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@/*": ["./src/*"]
    }
  }
}
tsconfig.app.json
{
  "compilerOptions": {
    "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
    "target": "ES2022",
    "useDefineForClassFields": true,
    "lib": ["ES2022", "DOM", "DOM.Iterable"],
    "module": "ESNext",
    "skipLibCheck": true,

    /* Bundler mode */
    "moduleResolution": "bundler",
    "allowImportingTsExtensions": true,
    "verbatimModuleSyntax": true,
    "moduleDetection": "force",
    "noEmit": true,
    "jsx": "react-jsx",

    /* Linting */
    "strict": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "erasableSyntaxOnly": true,
    "noFallthroughCasesInSwitch": true,
    "noUncheckedSideEffectImports": true
  },
  "include": ["src"],
  "baseUrl": ".",
  "paths": {
    "@/*": ["./src/*"]
  }
}
npm install -D @types/node
vite.config.ts
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
import path from "path";

// https://vite.dev/config/
export default defineConfig({
  plugins: [react(), tailwindcss()],
  resolve: {
    alias: {
      "@": path.resolve(__dirname, "./src"),
    },
  },
});
npx shadcn@latest init

Ok to proceed? (y) y

✔ Preflight checks.
✔ Verifying framework. Found Vite.
✔ Validating Tailwind CSS config. Found v4.
✔ Validating import alias.
✔ Which color would you like to use as the base color? › Neutral

ここまでは完全に公式ドキュメント通りなので詳しくは割愛します。
それではボタンコンポーネントを使えるように追加してみましょう

npx shadcn@latest add button

するとsrc/components/ui/button.tsxというファイルが作成されました

image.png

これがShadcn/UIが作ってくれているコンポーネントになります。
こちらを先程コンポーネントで習ったとおりにApp.tsxで利用してみましょう

src/App.tsx
import "./App.css";
import { Button } from "./components/ui/button";

function App() {
  return (
    <div>
      <h1 className="text-3xl font-bold underline">音楽生成アプリ</h1>
      <Button>生成する</Button>
    </div>
  );
}

export default App;

コンポーネントをまずは外部から利用できるようにインポート(読み込み)します。

import { Button } from "./components/ui/button";

そしてButtonコンポーネントを利用します。

      <Button>生成する</Button>

Buttonコンポーネントの使い方はこちらにあります。

image.png

リッチなボタンが表示されれば導入はうまくいっています。

5. React Routerでページを設定しよう

このチャプターで学べること

  • React Routerとは何か、なぜ使うのか
  • 複数ページの設定方法

音楽アプリを作るには複数のページが必要です。

  • 音楽一覧ページ
  • 音楽作成ページ

そこでReact Routerというルーティングライブラリを使います。

なぜReact Routerが必要なのか?

通常のWebサイトでは、ページを移動するたびに新しいHTMLファイルをサーバーからダウンロードします。

普通のWebサイトの場合
トップページ → index.html をダウンロード
お問い合わせページ → contact.html をダウンロード
商品ページ → product.html をダウンロード

しかしReactアプリは1つのHTMLファイルしか使いません
どのページを見ていても同じHTMLファイル(通常はindex.html)を使い、JavaScriptで画面の内容だけを変更します。

image.png

Reactアプリの場合
どのページでも → index.html(同じファイル)
画面の表示だけJavaScriptで切り替え

この仕組みを SPA(Single Page Application) と呼びます。

SPAの問題点

SPAには大きな問題があります。
どのページを見ていてもURLが http://localhost:5173/ のままになってしまうのです。

URLが変わらないことで以下の問題が発生します。

  • お気に入りページをブックマークできない
  • URLを友達に教えて特定のページを見せることができない
  • ブラウザの「戻る」ボタンが正常に動作しない

React Routerによる解決

React Routerを使うことで、URLと表示内容を連動させることができます。

http://localhost:5173/ → 音楽一覧ページを表示
http://localhost:5173/create → 音楽作成ページを表示

React Routerは「URLが変わったら、表示するコンポーネントを切り替える」という仕組みで、複数ページがあるように見せてくれます。

それでは実際にReact Routerを導入してみましょう。

React Routerの導入

こちらも公式ドキュメントどおりに設定をしていきます。
※ ReactRouterには2つのモードがありますが、今回はデータモード(フレームワークではない)を使います。

npm i react-router

それではルーティングを設定しましょう

src/main.tsx
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import "./index.css";
import App from "./App.tsx";
import { createBrowserRouter, RouterProvider } from "react-router";
import CreatePage from "./CreatePage.tsx";

const router = createBrowserRouter([
  {
    path: "/",
    element: <App />,
  },
  {
    path: "/create",
    element: <CreatePage />,
  },
]);

createRoot(document.getElementById("root")!).render(
  <StrictMode>
    <RouterProvider router={router} />
  </StrictMode>
);

React Routerから必要な機能を読み込みます。

import { createBrowserRouter, RouterProvider } from "react-router";

ここでルーティングを設定しています。

const router = createBrowserRouter([
  {
    path: "/",
    element: <App />,
  },
  {
    path: "/create",
    element: <CreatePage />,
  },
]);

今回は2つのページ(一覧と作成)があるので2つ設定しました。
pathはURLで、elementは表示するコンポーネントを指定しています。

path: "/" → URLがhttp://localhost:5173/ のときに
element: → Appコンポーネントを表示

作成したルート設定をReactアプリに適用します。

<RouterProvider router={router} />

ここで作成ページ(CreatePageコンポーネント)が必要なので作ります。

touch src/CreatePage.tsx
src/CreatePage.tsx
function CreatePage() {
  return <div>音楽作成ページ</div>;
}

export default CreatePage;

動作を確認してみましょう。
以下のURLにアクセスしてページが切り替わることを確認してください:

トップページが表示される http://localhost:5173/

image.png

作成ページが表示 http://localhost:5173/create

image.png

6. 音楽一覧ページを作成しよう

ここから実際に音楽アプリを作成していきます。
まずは事前に用意した音楽データを一覧に表示するところまでを行います。

このチャプターで学べること

  • 配列データの表示方法
  • mapメソッドの使い方
  • 音楽再生機能の実装
  • イベントハンドラの基本

音楽データを準備する

まずは表示する音楽データを用意しましょう。
src/App.tsxを以下のように修正します。

src/App.tsx
import "./App.css";

function App() {
  const musicList = [
    {
      id: 1,
      title: "Synthwave Dreams",
      artist: "AI Composer",
      audioUrl:
        "https://commondatastorage.googleapis.com/codeskulptor-demos/DDR_assets/Kangaroo_MusiQue_-_The_Neverwritten_Role_Playing_Game.mp3",
      coverUrl:
        "https://images.unsplash.com/photo-1493225457124-a3eb161ffa5f?w=400&h=400&fit=crop&crop=center",
    },
    {
      id: 2,
      title: "Jazz Fusion",
      artist: "Neural Network",
      audioUrl:
        "https://www.learningcontainer.com/wp-content/uploads/2020/02/Kalimba.mp3",
      coverUrl:
        "https://images.unsplash.com/photo-1511379938547-c1f69419868d?w=400&h=400&fit=crop&crop=center",
    },
    {
      id: 3,
      title: "Ambient Spaces",
      artist: "Deep Learning",
      audioUrl:
        "https://codeskulptor-demos.commondatastorage.googleapis.com/GalaxyInvaders/theme_01.mp3",
      coverUrl:
        "https://images.unsplash.com/photo-1514320291840-2e0a9bf2a9ae?w=400&h=400&fit=crop&crop=center",
    },
  ];

  return (
    <div>
      <h1>音楽一覧ページ</h1>
    </div>
  );
}

export default App;
const musicList = [
  // 音楽データの配列
];

musicListという変数に音楽の情報を配列として格納しています。
配列とは複数のデータをまとめて管理できるJavaScriptの仕組みです。

{
  id: 1,
  title: "Synthwave Dreams",
  artist: "AI Composer",
  audioUrl: "https://commondatastorage.googleapis.com/codeskulptor-demos/DDR_assets/Kangaroo_MusiQue_-_The_Neverwritten_Role_Playing_Game.mp3",
  coverUrl: "https://images.unsplash.com/photo-1493225457124-a3eb161ffa5f?w=400&h=400&fit=crop&crop=center",
}

各音楽は「オブジェクト」として表現されています。

id: 音楽を識別するための番号
title: 音楽のタイトル
artist: アーティスト名
audioUrl: 音楽ファイルのURL
coverUrl: 音楽のジャケット画像

音楽リストを画面に表示する

配列のデータを画面に表示するにはmapメソッドを使います。

import "./App.css";

function App() {
  const musicList = [
    {
      id: 1,
      title: "Synthwave Dreams",
      artist: "AI Composer",
      audioUrl:
        "https://commondatastorage.googleapis.com/codeskulptor-demos/DDR_assets/Kangaroo_MusiQue_-_The_Neverwritten_Role_Playing_Game.mp3",
      coverUrl:
        "https://images.unsplash.com/photo-1493225457124-a3eb161ffa5f?w=400&h=400&fit=crop&crop=center",
    },
    {
      id: 2,
      title: "Jazz Fusion",
      artist: "Neural Network",
      audioUrl:
        "https://www.learningcontainer.com/wp-content/uploads/2020/02/Kalimba.mp3",
      coverUrl:
        "https://images.unsplash.com/photo-1511379938547-c1f69419868d?w=400&h=400&fit=crop&crop=center",
    },
    {
      id: 3,
      title: "Ambient Spaces",
      artist: "Deep Learning",
      audioUrl:
        "https://codeskulptor-demos.commondatastorage.googleapis.com/GalaxyInvaders/theme_01.mp3",
      coverUrl:
        "https://images.unsplash.com/photo-1514320291840-2e0a9bf2a9ae?w=400&h=400&fit=crop&crop=center",
    },
  ];

  return (
    <div>
      <h1>音楽一覧ページ</h1>
      <ul>
        {musicList.map((music) => (
          <li key={music.id}>
            <h3>{music.title}</h3>
            <p>アーティスト: {music.artist}</p>
          </li>
        ))}
      </ul>
    </div>
  );
}

export default App;

http://localhost:5173にアクセスすると一覧が表示されます。

image.png

ここでいくつかの重要な概念が登場しています。

カーリーブレス

image.png

JSXの中でJavaScriptのコードを実行するには{}で囲みます。
よく利用するのはJavaScriptの変数を画面に表示するときなどです。

今回はここがJavaScriptのコードです。
musicListという変数に対してmapというメソッドを呼び出しています。

        {musicList.map((music) => (
          (省略)
        ))}

mapメソッド

mapは配列の各要素に対して処理を実行し、新しい配列を作成するメソッドです。

なぜ配列が画面に表示されるのか?

まず、mapメソッドがどのように動作するかを段階的に見てみましょう。

const musicList = [
  { id: 1, title: "Synthwave Dreams", artist: "AI Composer" },
  { id: 2, title: "Jazz Fusion", artist: "Neural Network" },
  { id: 3, title: "Ambient Spaces", artist: "Deep Learning" }
];

musicList.map((music) => (
  <li key={music.id}>
    <h3>{music.title}</h3>
    <p>アーティスト: {music.artist}</p>
  </li>
))

mapメソッドは以下のような処理を行います

// 1回目の実行
music = { id: 1, title: "Synthwave Dreams", artist: "AI Composer" }
<li key={1}>
  <h3>Synthwave Dreams</h3>
  <p>アーティスト: AI Composer</p>
</li>

// 2回目の実行
music = { id: 2, title: "Jazz Fusion", artist: "Neural Network" }
<li key={2}>
  <h3>Jazz Fusion</h3>
  <p>アーティスト: Neural Network</p>
</li>


// 3回目の実行
music = { id: 3, title: "Ambient Spaces", artist: "Deep Learning" }
<li key={3}>
  <h3>Ambient Spaces</h3>
  <p>アーティスト: Deep Learning</p>
</li>

mapメソッドによって、以下のような新しい配列が作成されます

[
  <li key={1}><h3>Synthwave Dreams</h3><p>アーティスト: AI Composer</p></li>,
  <li key={2}><h3>Jazz Fusion</h3><p>アーティスト: Neural Network</p></li>,
  <li key={3}><h3>Ambient Spaces</h3><p>アーティスト: Deep Learning</p></li>
]

Reactには「JSX要素の配列を自動的に展開して表示する」という機能があります。

// 以下の2つはJSXでは同じ
<ul>
  {[
    <li key={1}>項目1</li>,
    <li key={2}>項目2</li>,
    <li key={3}>項目3</li>
  ]}
</ul>

<ul>
  <li key={1}>項目1</li>
  <li key={2}>項目2</li>
  <li key={3}>項目3</li>
</ul>

なのでmapメソッドで<li>の配列を作ることで正しく表示されるのです。

なぜmapを使うのか?

配列を手動で表示する場合

<ul>
  <li><h3>{musicList[0].title}</h3><p>{musicList[0].artist}</p></li>
  <li><h3>{musicList[1].title}</h3><p>{musicList[1].artist}</p></li>
  <li><h3>{musicList[2].title}</h3><p>{musicList[2].artist}</p></li>
</ul>

mapを使う場合

<ul>
  {musicList.map((music) => (
    <li key={music.id}>
      <h3>{music.title}</h3>
      <p>{music.artist}</p>
    </li>
  ))}
</ul>

音楽が増えても手動で変更する必要がないので繰り返しの表示をする場合はmapを使います。

データの表示

<h3>{music.title}</h3>
<p>アーティスト: {music.artist}</p>

music.titlemusic.artistで各音楽のデータにアクセスすることができます。
(keyに関しての解説は飛ばしますがReactの重要な考え方の一つになりますので、各自調べてみてください)

音楽再生機能を追加する

音楽を再生できるようにボタンを追加します。

import "./App.css";

function App() {
  const musicList = [
    {
      id: 1,
      title: "Synthwave Dreams",
      artist: "AI Composer",
      audioUrl:
        "https://commondatastorage.googleapis.com/codeskulptor-demos/DDR_assets/Kangaroo_MusiQue_-_The_Neverwritten_Role_Playing_Game.mp3",
      coverUrl:
        "https://images.unsplash.com/photo-1493225457124-a3eb161ffa5f?w=400&h=400&fit=crop&crop=center",
    },
    {
      id: 2,
      title: "Jazz Fusion",
      artist: "Neural Network",
      audioUrl:
        "https://www.learningcontainer.com/wp-content/uploads/2020/02/Kalimba.mp3",
      coverUrl:
        "https://images.unsplash.com/photo-1511379938547-c1f69419868d?w=400&h=400&fit=crop&crop=center",
    },
    {
      id: 3,
      title: "Ambient Spaces",
      artist: "Deep Learning",
      audioUrl:
        "https://codeskulptor-demos.commondatastorage.googleapis.com/GalaxyInvaders/theme_01.mp3",
      coverUrl:
        "https://images.unsplash.com/photo-1514320291840-2e0a9bf2a9ae?w=400&h=400&fit=crop&crop=center",
    },
  ];

  // 音楽を再生する関数
  const playMusic = (audioUrl: string) => {
    const audio = new Audio(audioUrl);
    audio.play();
  };

  return (
    <div>
      <h1>音楽一覧ページ</h1>
      <ul>
        {musicList.map((music) => (
          <li key={music.id}>
            <h3>{music.title}</h3>
            <p>アーティスト: {music.artist}</p>
            <button onClick={() => playMusic(music.audioUrl)}>再生</button>
          </li>
        ))}
      </ul>
    </div>
  );
}

export default App;

これで再生ボタンをおすと音楽が再生されます。

image.png

音楽再生の仕組みを解説していきます。
まずはボタンをクリックしたときに関数を呼び出す設定をしました。

<button onClick={() => playMusic(music.audioUrl)}>
  再生
</button>

onClickというのはボタンがクリックされたときに実行されるイベントハンドラと呼ばれるものです。

image.png

クリックしたら() => playMusic(music.audioUrl)(アロー関数)が呼ばれて
その中でplayMusic関数が実行されます。関数へ引数としてクリックした音楽のaudioUrlを渡します。

const playMusic = (audioUrl: string) => {
  const audio = new Audio(audioUrl);
  audio.play();
};

音楽を再生するための関数です。

new Audio(audioUrl): 音楽ファイルを読み込む
audio.play(): 音楽を再生する

これはHTMLに標準であるAudioを利用しているだけです。

今回は事前に用意しておいた音楽と生成した音楽をセクションに分けて表示したいと思うので、以下のようにします。

src/App.tsx
import "./App.css";

function App() {
  const musicList = [
    {
      id: 1,
      title: "Synthwave Dreams",
      artist: "AI Composer",
      audioUrl:
        "https://commondatastorage.googleapis.com/codeskulptor-demos/DDR_assets/Kangaroo_MusiQue_-_The_Neverwritten_Role_Playing_Game.mp3",
      coverUrl:
        "https://images.unsplash.com/photo-1493225457124-a3eb161ffa5f?w=400&h=400&fit=crop&crop=center",
    },
    {
      id: 2,
      title: "Jazz Fusion",
      artist: "Neural Network",
      audioUrl:
        "https://www.learningcontainer.com/wp-content/uploads/2020/02/Kalimba.mp3",
      coverUrl:
        "https://images.unsplash.com/photo-1511379938547-c1f69419868d?w=400&h=400&fit=crop&crop=center",
    },
    {
      id: 3,
      title: "Ambient Spaces",
      artist: "Deep Learning",
      audioUrl:
        "https://codeskulptor-demos.commondatastorage.googleapis.com/GalaxyInvaders/theme_01.mp3",
      coverUrl:
        "https://images.unsplash.com/photo-1514320291840-2e0a9bf2a9ae?w=400&h=400&fit=crop&crop=center",
    },
  ];
  
  const playMusic = (audioUrl: string) => {
    const audio = new Audio(audioUrl);
    audio.play();
  };

  return (
    <div className="p-6">
      <h1 className="text-2xl font-bold mb-6">音楽一覧ページ</h1>
      
      <section className="mb-8">
        <h2 className="text-xl font-bold mb-4">作成した音楽</h2>
        <p>このあと実装する</p>
      </section>
      
      <section>
        <h2 className="text-xl font-bold mb-4">おすすめの音楽</h2>
        <div className="flex gap-4">
          {musicList.map((music) => (
            <div key={music.id} className="border p-4 rounded">
              <img
                src={music.coverUrl}
                alt={music.title}
                width="150"
                height="150"
                className="rounded mb-2"
              />
              <h3 className="font-bold">{music.title}</h3>
              <p className="text-gray-600 text-sm">{music.artist}</p>
              <button 
                onClick={() => playMusic(music.audioUrl)}
                className="mt-2 bg-blue-500 text-white px-3 py-1 rounded"
              >
                再生
              </button>
            </div>
          ))}
        </div>
      </section>
    </div>
  );
}

export default App;

image.png

TailwindCSSで少しスタイリングをしました。
それでは次に音楽作成ページを実装しましょう

7. AIを使って音楽生成をしよう

次に音楽生成のページを実装します。
「楽曲のタイトル」「ジャンル」を入力することでAIが音楽を生成してくれる機能を作ります。

国内で音楽生成AIを無料で利用できるものがなかったので、今回は海外のサービスを利用します。

まずはそれぞれアカウント作成をしてください。
ログインできたら左下の3点リーダーから「API」をクリック

image.png

右上から「My Apps」をクリックして「Create App」を選択

image.png

プロジェクト情報を入力
App Names: music-creator-app-xxxx (ここは世界でユニークなので好きな名前をつけてください)
Description: music generation app
Website : localhost:5173

image.png

すると「API Key」にAPIに接続するための接続情報がそれぞれ表示されるのでメモしてください

image.png

環境変数の設定

APIキーを安全に管理するために環境変数ファイルを作成します。
プロジェクトのルートディレクトリ(package.jsonと同じ場所)に.envファイルを作成してください。

touch .env

.envファイルに先ほど取得したAPIキーを設定します

.env
VITE_LOUDLY_API_KEY=your_api_key_here

your_api_key_hereの部分を実際のAPIキーに置き換えてください。
コードに直接環境変数を書いてしまうと誰でもAPIキーを利用できてしまうため、勝手に利用して音楽生成ができるようになります。すると高額な請求などにつながるので.envなど外部ファイルで管理します。

.envはGitHubなどにも載せてしまうと例え非公開のリポジトリでもバレてしまう可能性があるので.envは公開しないようにしましょう。(今回は省略します)

音楽作成フォームを作成する

音楽生成用のフォームを作成しましょう。

src/CreatePage.tsxを以下のように修正します

src/CreatePage.tsx
import { useState } from "react";

function CreatePage() {
  const [title, setTitle] = useState("");
  const [genre, setGenre] = useState("");
  const [prompt, setPrompt] = useState("");

  const handleGenerate = () => {
    console.log("音楽生成開始:", { title, genre, prompt });
    // ここで音楽生成処理を実装予定
  };

  return (
    <div>
      <h1>音楽作成ページ</h1>
      
      <div>
        <div>
          <label>楽曲タイトル</label>
          <input
            type="text"
            value={title}
            onChange={(e) => setTitle(e.target.value)}
            placeholder="楽曲のタイトルを入力"
          />
        </div>

        <div>
          <label>ジャンル</label>
          <select
            value={genre}
            onChange={(e) => setGenre(e.target.value)}
          >
            <option value="">ジャンルを選択</option>
            <option value="electronic">エレクトロニック</option>
            <option value="jazz">ジャズ</option>
            <option value="classical">クラシック</option>
            <option value="ambient">アンビエント</option>
            <option value="rock">ロック</option>
            <option value="pop">ポップ</option>
          </select>
        </div>

        <div>
          <label>音楽の説明</label>
          <textarea
            value={prompt}
            onChange={(e) => setPrompt(e.target.value)}
            placeholder="どんな音楽を作りたいか説明してください"
          />
        </div>

        <button onClick={handleGenerate}>
          音楽を生成
        </button>
      </div>
    </div>
  );
}

export default CreatePage;

http://localhost:5173/createにアクセスします。

image.png

タイトルとジャンルを選択、音楽の説明を入力して「音楽を生成」をクリックするとコンソールに表示されます。
コンソールは画面を右クリックして「検証」から「Console」を選択すると表示されます。

image.png

ここでReactの重要な概念であるuseStateを解説します。

今回は音楽生成に「タイトル」「ジャンル」「説明」の3つの要素が必要です。
これらはReactの中で ステート(状態) と呼ばれています。

image.png

例えば入力されたタイトルを表示するとします。
ここでuseStateでなくJavaScriptの変数でタイトルを管理したとしましょう

function CreatePage() {
  let title = ""; // 普通のJavaScript変数
  const handleChange = (e) => {
    title = e.target.value; // 変数の値は変わる
    console.log(title); // コンソールには新しい値が表示される
  };
  
  return (
    <div>
      <input onChange={handleChange} />
      <p>入力されたタイトル: {title}</p> {/* 画面には反映されない! */}
    </div>
  );
}

この場合titleの値は変更されますが画面のタイトルの表示は変更されません。
Reactで変数の状態(ステート)によって画面を再描画(再レンダリング)をするにはuseStateで変数を管理する必要があります。難しいと感じる方は値が変わる可能性のある変数はuseStateを使うと覚えても最初は問題ありません。

今回の音楽生成に使う3つの項目もuseStateで管理しています。

  const [title, setTitle] = useState("");
  const [genre, setGenre] = useState("");
  const [prompt, setPrompt] = useState("");

image.png

useState関数は2つの値を返します。

  const [title, setTitle] = useState("");

titleはタイトルの値、setTitleはタイトルの値を更新するための関数です。
useStateの引数にはタイトルの初期値を設定します。今回は空文字です。

          <input
            type="text"
            value={title}
            onChange={(e) => setTitle(e.target.value)}
            placeholder="楽曲のタイトルを入力"
          />

ステートの更新はインプットフォームに文字列が入力されるたびに行われます。

            onChange={(e) => setTitle(e.target.value)}

onChangeというイベントハンドラを使うことで1文字入力するたびに(e) => setTitle(e.target.value)が実行されます。

インプットフォームの状態はイベント(e)から取得できます。
インプットフォームに入力されている文字はe.target.valueで受け取れるのでその値を使ってタイトルの更新を行っています。

他の項目も同様にonChangeで値の更新を行っています。
「音楽を生成」をクリックするとhandleGenerateが実行されて現在のステートをコンソールに表示しています。

function CreatePage() {
  const [title, setTitle] = useState("");
  const [genre, setGenre] = useState("");
  const [prompt, setPrompt] = useState("");

  const handleGenerate = () => {
    console.log("音楽生成開始:", { title, genre, prompt });
    // ここで音楽生成処理を実装予定
  };

(省略)

        <button onClick={handleGenerate}>
          音楽を生成
        </button>

音楽生成APIを叩いてみよう

Loudlyを使って実勢に音楽生成をしてみましょう
APIを叩くために(HTTPリクエストを送信する)ためのライブラリをインストールします

npm i axios

JavaScriptにはfetchというHTTPリクエストができる仕組みがありますが、初心者にとって理解しやすいaxiosを今回は利用することにします。

src/CreatePage.tsx
import { useState } from "react";
import axios from "axios";

function CreatePage() {
  const [title, setTitle] = useState("");
  const [genre, setGenre] = useState("");
  const [prompt, setPrompt] = useState("");
  const [generatedMusic, setGeneratedMusic] = useState("");

  const handleGenerate = async () => {
    const apiKey = import.meta.env.VITE_LOUDLY_API_KEY;

    try {
      const formData = new FormData();
      const musicPrompt = `Create a ${genre} song titled "${title}". Musical style: ${prompt}. High quality production with clear melody and rhythm.`;
      formData.append("prompt", musicPrompt);
      formData.append("duration", "30");

      const response = await axios.post(
        "https://soundtracks.loudly.com/api/ai/prompt/songs",
        formData,
        {
          headers: {
            "API-KEY": apiKey,
          },
        }
      );

      if (response.data && response.data.music_file_path) {
        setGeneratedMusic(response.data.music_file_path);
      }
    } catch (error) {
      console.error("エラー:", error);
      alert("音楽生成に失敗しました");
    }
  };

  return (
    <div>
      <h1>音楽作成ページ</h1>

      <div>
        <div>
          <label>楽曲タイトル</label>
          <input
            type="text"
            value={title}
            onChange={(e) => setTitle(e.target.value)}
            placeholder="楽曲のタイトルを入力"
          />
        </div>

        <div>
          <label>ジャンル</label>
          <select value={genre} onChange={(e) => setGenre(e.target.value)}>
            <option value="">ジャンルを選択</option>
            <option value="electronic">エレクトロニック</option>
            <option value="jazz">ジャズ</option>
            <option value="classical">クラシック</option>
            <option value="ambient">アンビエント</option>
            <option value="rock">ロック</option>
            <option value="pop">ポップ</option>
          </select>
        </div>

        <div>
          <label>音楽の説明</label>
          <textarea
            value={prompt}
            onChange={(e) => setPrompt(e.target.value)}
            placeholder="どんな音楽を作りたいか説明してください"
          />
        </div>

        <button onClick={handleGenerate}>音楽を生成</button>

        {generatedMusic && (
          <div>
            <h3>生成された音楽</h3>
            <audio controls>
              <source src={generatedMusic} type="audio/mpeg" />
            </audio>
          </div>
        )}
      </div>
    </div>
  );
}

export default CreatePage;

それぞれ入力をして「音楽を生成」を押すと数秒経過してから音楽が聞けるようになります。

image.png

  const handleGenerate = async () => {
   // 音楽生成する処理
  };

ボタンをクリックしたら音楽生成の処理が始まります。
ここでasyncというのが関数についており、この関数は非同期関数になっています。

ここで初心者がつまづきやすい非同期処理について詳しく解説していきます。
まずは「同期処理」と「非同期処理」の違いから理解していきましょう。

カレーとサラダを作ることを想像してください

image.png

同期処理は順番に作業をする方法です。カレーを作り終わってからサラダを作成します。
つまりカレーを煮込んでいる間はずっと鍋を見つめているのが「同期処理」です。

それに対しておそらく皆さんがやっているのは「非同期処理」です。
鍋を煮込んでいる間にサラダを作ることで時間の節約をしています。

これをよりプログラミング的に説明すると、自分が作業する主要作業を「メインスレッド」と呼びます。
同期処理ではメインスレッドのみですべての作業を行うため直列的になってしまいます。

しかし、煮込みの時間は別の作業をしたいと考えるので煮込みの作業になったタイミングでメインスレッドから優先度を落として裏側で並列して煮込みを行いつつ、メインスレッドではサラダを作るということをします。

今回のAPIを叩いてデータ取得するのにも時間がかかるので非同期にしてあげることで、メインスレッドではユーザーはアプリを操作することができます。

image.png

  const handleGenerate = async () => {
    const apiKey = import.meta.env.VITE_LOUDLY_API_KEY;

    try {
      const formData = new FormData();
      const musicPrompt = `Create a ${genre} song titled "${title}". Musical style: ${prompt}. High quality production with clear melody and rhythm.`;
      formData.append("duration", "30");

      const response = await axios.post(
        "https://soundtracks.loudly.com/api/ai/prompt/songs",
        formData,
        {
          headers: {
            "API-KEY": apiKey,
          },
        }
      );

      if (response.data && response.data.music_file_path) {
        setGeneratedMusic(response.data.music_file_path);
      }
    } catch (error) {
      console.error("エラー:", error);
      alert("音楽生成に失敗しました");
    }
  };

そして中ではまず環境変数で設定したLoudlyのAPIキーの値を取得します。

    const apiKey = import.meta.env.VITE_LOUDLY_API_KEY;

今回叩く音楽を生成するAPIは以下を利用します。

以下のエンドポイントにPOSTリクエストをします。

image.png

送るリクエストにはこれらが指定できます。

image.png

今回はpromptduration(生成する音楽の時間)を設定します。

      const formData = new FormData();

      const musicPrompt = `Create a ${genre} song titled "${title}". Musical style: ${prompt}. High quality production with clear melody and rhythm.`;

      formData.append("prompt", musicPrompt);
      formData.append("duration", "30");

formDataに追加することでPOSTリクエストで送ることができるようになります。

      const response = await axios.post(
        "https://soundtracks.loudly.com/api/ai/prompt/songs",
        formData,
        {
          headers: {
            "API-KEY": apiKey,
          },
        }
      );

次にAxiosで実際にリクエストを送ります。
先程のエンドポイントにformDataを一緒に送り、APIキーをヘッダーに付与することで正しいユーザーでのリクエストであることを確かめています。

  const [generatedMusic, setGeneratedMusic] = useState("");

  (省略)
  
      if (response.data && response.data.music_file_path) {
        setGeneratedMusic(response.data.music_file_path);
      }

もし変数responseのdataに値があればレスポンスの音楽のURLをステートに保存します。
レスポンスはドキュメントにこのようにあります。

image.png

music_file_pathで生成した音楽のURLを取得できるので利用しました。

Tryの中でAPIリクエストをすることでもし音楽生成に失敗した場合はcatchにいきます。

    try {
    /// APIを叩く
    } catch (error) {
      console.error("エラー:", error);
      alert("音楽生成に失敗しました");
    }
  };

例外が発生したときにtryをしておくことでアプリ全体が停止してしまうのを防ぐことができます。
今回は例外発生したときはアプリが停止する代わりにアラートでその旨を伝えるようにしています。

音楽が生成されると再生ボタンが表示されます。

        {generatedMusic && (
          <div>
            <h3>生成された音楽</h3>
            <audio controls>
              <source src={generatedMusic} type="audio/mpeg" />
            </audio>
          </div>
        )}
{generateMusic && (省略)}

とすることでgenerateMusicがあるなら(nullでないなら)再生ボタンが表示されるようになります。
生成前には再生できないようになっています。

8. 生成した音楽を保存・表示しよう

音楽生成ができるようになったのでここからは生成した音楽をローカルストレージに保存してトップページで表示するようにします。

このチャプターで学べること

  • ローカルストレージへの保存方法
  • useEffectの基本的な使い方
  • TypeScriptの型

生成した音楽の保存

まずはローカルストレージについて詳しく解説します。

データはどこに保存される?

Webアプリでデータを保存する方法はいくつかありますが、それぞれ保存される場所が異なります。

1. 変数(一時的)

const [title, setTitle] = useState("音楽のタイトル")

ブラウザのメモリに保存される。ページを閉じると消える

2. サーバー

DBを用意して保存する一般的な方法だがDBの用意が必要なので複雑になる

3. ローカルストレージ

ブラウザ内のストレージに保存する方法

メリット:

  • ページを閉じてもデータが残る
  • サーバー不要で簡単に使える
  • 同じサイト内なら他のページからもアクセス可能

デメリット:

  • 他のブラウザからは見えない(Chrome で保存したデータは Firefox では見えない)
  • ユーザーがデータを削除する可能性がある
  • 文字列データのみ保存可能(数字や配列は文字列に変換する必要がある)

今回は簡単に保存したいのでローカルストレージを採用しました。
それでは実際に生成した音楽をローカルストレージに保存する機能を追加します。

src/CreatePage.tsx
import { useState } from "react";
import axios from "axios";

function CreatePage() {
  const [title, setTitle] = useState("");
  const [genre, setGenre] = useState("");
  const [prompt, setPrompt] = useState("");
  const [generatedMusic, setGeneratedMusic] = useState("");
  
  const handleGenerate = async () => {
    const apiKey = import.meta.env.VITE_LOUDLY_API_KEY;
    try {
      const formData = new FormData();
      const musicPrompt = `Create a ${genre} song titled "${title}". Musical style: ${prompt}. High quality production with clear melody and rhythm.`;
      formData.append("prompt", musicPrompt);
      formData.append("duration", "30");
      const response = await axios.post(
        "https://soundtracks.loudly.com/api/ai/prompt/songs",
        formData,
        {
          headers: {
            "API-KEY": apiKey,
          },
        }
      );
      if (response.data && response.data.music_file_path) {
        setGeneratedMusic(response.data.music_file_path);
      }
    } catch (error) {
      console.error("エラー:", error);
      alert("音楽生成に失敗しました");
    }
  };
  const handleSave = () => {
    if (!generatedMusic || !title || !genre) {
      alert("音楽を生成してから保存してください");
      return;
    }
    const musicData = {
      id: Date.now().toString(),
      title: title,
      artist: "AI Generated",
      audioUrl: generatedMusic,
      coverUrl: `https://picsum.photos/400/400?random=${Date.now()}`,
    };

    const savedMusic = JSON.parse(
      localStorage.getItem("generatedMusic") || "[]"
    );
    savedMusic.push(musicData);
    localStorage.setItem("generatedMusic", JSON.stringify(savedMusic));

    alert("音楽を保存しました!");
  };

  return (
    <div>
      <h1>音楽作成ページ</h1>{" "}
      <div>
        <div>
          <label>楽曲タイトル</label>
          <input
            type="text"
            value={title}
            onChange={(e) => setTitle(e.target.value)}
            placeholder="楽曲のタイトルを入力"
          />
        </div>{" "}
        <div>
          <label>ジャンル</label>
          <select value={genre} onChange={(e) => setGenre(e.target.value)}>
            <option value="">ジャンルを選択</option>
            <option value="electronic">エレクトロニック</option>
            <option value="jazz">ジャズ</option>
            <option value="classical">クラシック</option>
            <option value="ambient">アンビエント</option>
            <option value="rock">ロック</option>
            <option value="pop">ポップ</option>
          </select>
        </div>{" "}
        <div>
          <label>音楽の説明</label>
          <textarea
            value={prompt}
            onChange={(e) => setPrompt(e.target.value)}
            placeholder="どんな音楽を作りたいか説明してください"
          />
        </div>{" "}
        <button onClick={handleGenerate}>音楽を生成</button>{" "}
        {generatedMusic && (
          <div>
            <h3>生成された音楽: {title}</h3>
            <audio controls>
              <source src={generatedMusic} type="audio/mpeg" />
            </audio>
            <br />
            <button onClick={handleSave}>音楽を保存</button>
          </div>
        )}
      </div>
    </div>
  );
}

export default CreatePage;

image.png

音楽を保存すると検証ツールの「Application」「Local Storage」「localhost:5173」から保存されているのが確認できます。

image.png

まずは保存するボタンを追加しました

        {generatedMusic && (
          <div>
            <h3>生成された音楽: {title}</h3>
            <audio controls>
              <source src={generatedMusic} type="audio/mpeg" />
            </audio>
            <br />
            <button onClick={handleSave}>音楽を保存</button>
          </div>
        )}

クリックするとhandleSaveが実行されます。

  const handleSave = () => {
    if (!generatedMusic || !title || !genre) {
      alert("音楽を生成してから保存してください");
      return;
    }
    const musicData = {
      id: Date.now().toString(),
      title: title,
      artist: "AI Generated",
      audioUrl: generatedMusic,
      coverUrl: `https://picsum.photos/400/400?random=${Date.now()}`,
    };

    const savedMusic = JSON.parse(
      localStorage.getItem("generatedMusic") || "[]"
    );
    savedMusic.push(musicData);
    localStorage.setItem("generatedMusic", JSON.stringify(savedMusic));

    alert("音楽を保存しました!");
  };

もしhandleSaveが呼ばれて曲が生成されていなかった場合は保存できないのでアラートを出して処理を止めるようにしています。(タイトルやジャンルを消して保存しようとした場合も同様です)

    if (!generatedMusic || !title || !genre) {
     alert("音楽を生成してから保存してください");
     return;
   }

次にトップページで作成したオブジェクトと同じ形式のJSONデータを作成します。

    const musicData = {
      id: Date.now().toString(),
      title: title,
      artist: "AI Generated",
      audioUrl: generatedMusic,
      coverUrl: `https://picsum.photos/400/400?random=${Date.now()}`,
    };

idは適当に日付を設定しました。
coverUrlはランダムな画像を設定しています。

そしてローカルストレージに保存しますが、すでに保存されている音楽があるかもしれないのでローカルストレージからデータ取得を最初にします

    const savedMusic = JSON.parse(
      localStorage.getItem("generatedMusic") || "[]"
    );

もしデータがなければ新しい配列を作成します。

    savedMusic.push(musicData);
    localStorage.setItem("generatedMusic", JSON.stringify(savedMusic));

配列にオブジェクトを追加(push)してローカルストレージにgenerateMusicというキーで保存します。
あとはトップページでローカルストレージからデータを取得して事前データと同じように表示すればOKです。

生成した音楽の表示

それではローカルストレージに保存したデータをトップページに表示してみましょう
ここでReactでよく使うHooksの1つであるuseEeffectを使います。

src/App.tsx
import { useState, useEffect } from "react";
import "./App.css";

type Music = {
  id: number;
  title: string;
  artist: string;
  audioUrl: string;
  coverUrl: string;
};

function App() {
  const musicList = [
    {
      id: 1,
      title: "Synthwave Dreams",
      artist: "AI Composer",
      audioUrl:
        "https://commondatastorage.googleapis.com/codeskulptor-demos/DDR_assets/Kangaroo_MusiQue_-_The_Neverwritten_Role_Playing_Game.mp3",
      coverUrl:
        "https://images.unsplash.com/photo-1493225457124-a3eb161ffa5f?w=400&h=400&fit=crop&crop=center",
    },
    {
      id: 2,
      title: "Jazz Fusion",
      artist: "Neural Network",
      audioUrl:
        "https://www.learningcontainer.com/wp-content/uploads/2020/02/Kalimba.mp3",
      coverUrl:
        "https://images.unsplash.com/photo-1511379938547-c1f69419868d?w=400&h=400&fit=crop&crop=center",
    },
    {
      id: 3,
      title: "Ambient Spaces",
      artist: "Deep Learning",
      audioUrl:
        "https://codeskulptor-demos.commondatastorage.googleapis.com/GalaxyInvaders/theme_01.mp3",
      coverUrl:
        "https://images.unsplash.com/photo-1514320291840-2e0a9bf2a9ae?w=400&h=400&fit=crop&crop=center",
    },
  ];

  const [generatedMusic, setGeneratedMusic] = useState<Music[]>([]);

  useEffect(() => {
    const savedMusic = JSON.parse(
      localStorage.getItem("generatedMusic") || "[]"
    );
    setGeneratedMusic(savedMusic);
  }, []);

  const playMusic = (audioUrl: string) => {
    const audio = new Audio(audioUrl);
    audio.play();
  };

  return (
    <div className="p-6">
      <h1 className="text-2xl font-bold mb-6">音楽一覧ページ</h1>

      {generatedMusic.length > 0 && (
        <section className="mb-8">
          <h2 className="text-xl font-bold mb-4">作成した音楽</h2>
          <div className="flex gap-4">
            {generatedMusic.map((music) => (
              <div key={music.id} className="border p-4 rounded">
                <img
                  src={music.coverUrl}
                  alt={music.title}
                  width="150"
                  height="150"
                  className="rounded mb-2"
                />
                <h3 className="font-bold">{music.title}</h3>
                <p className="text-gray-600 text-sm">{music.artist}</p>
                <button
                  onClick={() => playMusic(music.audioUrl)}
                  className="mt-2 bg-blue-500 text-white px-3 py-1 rounded"
                >
                  再生
                </button>
              </div>
            ))}
          </div>
        </section>
      )}

      <section>
        <h2 className="text-xl font-bold mb-4">おすすめの音楽</h2>
        <div className="flex gap-4">
          {musicList.map((music) => (
            <div key={music.id} className="border p-4 rounded">
              <img
                src={music.coverUrl}
                alt={music.title}
                width="150"
                height="150"
                className="rounded mb-2"
              />
              <h3 className="font-bold">{music.title}</h3>
              <p className="text-gray-600 text-sm">{music.artist}</p>
              <button
                onClick={() => playMusic(music.audioUrl)}
                className="mt-2 bg-blue-500 text-white px-3 py-1 rounded"
              >
                再生
              </button>
            </div>
          ))}
        </div>
      </section>
    </div>
  );
}

export default App;

image.png

まずはuseEffectの部分を解説していきます。

image.png

useEffectはコンポーネントの副作用(画面表示以外の処理)を実行するための仕組みです。
今回のローカルストレージからデータを取得するというのも画面表示のするために必要な処理なので副作用にあたります。

useEffectは画面表示する後に実行されるので、そのデータを使って表するHTMLを組み立てられます(つまり取得したデータをJavaScriptの変数で利用可能)

  const [generatedMusic, setGeneratedMusic] = useState<Music[]>([]);

  useEffect(() => {
    const savedMusic = JSON.parse(
      localStorage.getItem("generatedMusic") || "[]"
    );
    setGeneratedMusic(savedMusic);
  }, []);

今回はローカルストレージから取得した文字列をJSONに変更しています。
もしキーgeneratedMusicがない場合はまだ生成されているものがないので空配列にしました。

そしてステートとしてgeneratedMusicを用意したのでステートを更新します。
ポイントはgeneratedMusicに型定義をしたことです。

type Music = {
  id: number;
  title: string;
  artist: string;
  audioUrl: string;
  coverUrl: string;
};

ここまで実はTypeScriptらしいコードは書いていませんでしたが、なぜTypeScriptが必要なのかを解説します。
たとえ型定義なしにステートを定義したとしましょう

// <Music[]>がない
  const [generatedMusic, setGeneratedMusic] = useState<Music[]>([]);

このときローカルストレージのオブジェクトにはどんな値が入る可能性もあります。
例えば、別の開発者がCreatePage.tsxで音楽を保存する際に、間違って以下のようなコードを書いたとします

// 間違った保存(nameプロパティを使用)
const musicData = {
  id: Date.now().toString(),
  name: title,  // ❌ titleではなくnameを使ってしまった
  artist: "AI Generated",
  audioUrl: generatedMusic,
  coverUrl: `https://picsum.photos/400/400?random=${Date.now()}`
};

この場合、ローカルストレージには以下のようなデータが保存されます

[
  {
    "id": "1701234567890",
    "name": "Synthwave Dreams",  // titleではなくname
    "artist": "AI Generated",
    "audioUrl": "https://example.com/music.mp3",
    "coverUrl": "https://picsum.photos/400/400?random=1701234567890"
  }
]

この状態で音楽データを表示しようとすると

{generatedMusic.map((music) => (
  <div key={music.id}>
    <h3>{music.title}</h3>  {/* ❌ titleは存在しない! */}
    <p>{music.artist}</p>
  </div>
))}

アプリ全体がクラッシュしてしまいます。
ここでTypeScriptの型を使うと事前に防ぐことが可能です。

src/CreatePage.tsx
type Music = {
  id: number;
  title: string;
  artist: string;
  audioUrl: string;
  coverUrl: string;
};

const musicData: Music = {
  id: Date.now(),
  name: title,  // ❌ エラー!'name'プロパティは型'Music'に存在しません
  artist: "AI Generated",
  audioUrl: generatedMusic,
  coverUrl: `https://picsum.photos/400/400?random=${Date.now()}`
};

image.png

また私たちはローカルストレージにtitleで音楽のタイトルが入っていることを知っていますが、新しく入ってきた人がたまたまtitleでなくトップページをnameを利用したとしましょう

image.png

JavaScriptであれば実際に画面を確認するまでtitleであったことには気付けませんが、TypeScriptであればエディタがエラーを教えてくれるため効率の良い開発が可能です。

9. 高度な音楽プレーヤー機能を実装しよう

このチャプターで学べること
より複雑なステート管理
HTMLAudioElementとの詳細な連携
条件分岐とコンポーネントの表示制御
イベントリスナーの管理

これまでシンプルな再生機能を作ってきましたが、より高度な音楽プレーヤー機能が必要です。モーダル内で音楽の再生・一時停止・シーク機能を実装していきます。
またスタイリングも同時に行いましょう

# Lucide React (アイコン用)
npm install lucide-react

# shadcn/ui の追加コンポーネント
npx shadcn@latest add card
npx shadcn@latest add dialog

まずはTopページからです。

src/App.tsx
import { useState, useEffect } from "react";
import { Play, Pause, Plus } from "lucide-react";
import { Dialog, DialogContent, DialogHeader } from "./components/ui/dialog";
import { Card } from "./components/ui/card";
import { Button } from "./components/ui/button";
import { DialogTitle } from "@radix-ui/react-dialog";
import { Link } from "react-router";

type Music = {
  id: number;
  title: string;
  artist: string;
  audioUrl: string;
  coverUrl: string;
};

function App() {
  const musicList = [
    {
      id: 1,
      title: "Synthwave Dreams",
      artist: "AI Composer",
      audioUrl:
        "https://commondatastorage.googleapis.com/codeskulptor-demos/DDR_assets/Kangaroo_MusiQue_-_The_Neverwritten_Role_Playing_Game.mp3",
      coverUrl:
        "https://images.unsplash.com/photo-1493225457124-a3eb161ffa5f?w=400&h=400&fit=crop&crop=center",
    },
    {
      id: 2,
      title: "Jazz Fusion",
      artist: "Neural Network",
      audioUrl:
        "https://www.learningcontainer.com/wp-content/uploads/2020/02/Kalimba.mp3",
      coverUrl:
        "https://images.unsplash.com/photo-1511379938547-c1f69419868d?w=400&h=400&fit=crop&crop=center",
    },
    {
      id: 3,
      title: "Ambient Spaces",
      artist: "Deep Learning",
      audioUrl:
        "https://codeskulptor-demos.commondatastorage.googleapis.com/GalaxyInvaders/theme_01.mp3",
      coverUrl:
        "https://images.unsplash.com/photo-1514320291840-2e0a9bf2a9ae?w=400&h=400&fit=crop&crop=center",
    },
  ];

  const [generatedMusic, setGeneratedMusic] = useState<Music[]>([]);
  const [selectedAlbum, setSelectedAlbum] = useState<Music | null>(null);
  const [isPlaying, setIsPlaying] = useState(false);
  const [currentTime, setCurrentTime] = useState(0);
  const [duration, setDuration] = useState(0);
  const [audio, setAudio] = useState<HTMLAudioElement | null>(null);

  useEffect(() => {
    const savedMusic = JSON.parse(
      localStorage.getItem("generatedMusic") || "[]"
    );
    setGeneratedMusic(savedMusic);
  }, []);

  const handlePlayPause = () => {
    if (!selectedAlbum) return;

    if (!audio) {
      const newAudio = new Audio(selectedAlbum.audioUrl);
      newAudio.addEventListener("loadedmetadata", () => {
        setDuration(newAudio.duration);
      });
      newAudio.addEventListener("timeupdate", () => {
        setCurrentTime(newAudio.currentTime);
      });
      newAudio.addEventListener("ended", () => {
        setIsPlaying(false);
        setCurrentTime(0);
      });
      setAudio(newAudio);
      newAudio.play().catch(console.error);
      setIsPlaying(true);
    } else {
      if (isPlaying) {
        audio.pause();
        setIsPlaying(false);
      } else {
        audio.play().catch(console.error);
        setIsPlaying(true);
      }
    }
  };

  const handleSeek = (e: React.ChangeEvent<HTMLInputElement>) => {
    if (audio) {
      const seekTime = parseFloat(e.target.value);
      audio.currentTime = seekTime;
      setCurrentTime(seekTime);
    }
  };

  const formatTime = (time: number) => {
    const minutes = Math.floor(time / 60);
    const seconds = Math.floor(time % 60);
    return `${minutes}:${seconds.toString().padStart(2, "0")}`;
  };

  useEffect(() => {
    if (audio && selectedAlbum) {
      audio.pause();
      audio.currentTime = 0;
      setIsPlaying(false);
      setCurrentTime(0);
      setDuration(0);
      setAudio(null);
    }
  }, [selectedAlbum?.id]);

  useEffect(() => {
    return () => {
      if (audio) {
        audio.pause();
        setAudio(null);
      }
    };
  }, [audio]);

  return (
    <div className="min-h-screen bg-black text-white">
      <main className="w-full">
        <header className="bg-gradient-to-b from-green-900/40 to-black/60 px-8 py-6">
          <div className="flex items-center justify-between mb-8">
            <h1 className="text-3xl font-bold text-white">MusicGen Studio</h1>
            <Link to="/create">
              <Button className="bg-green-500 hover:bg-green-400 text-black font-semibold px-6 py-2 rounded-full">
                <Plus className="w-4 h-4 mr-2" />
                Generate Music
              </Button>
            </Link>
          </div>

          <div className="mb-8">
            <h2 className="text-5xl font-bold text-white mb-4">Good evening</h2>
            <p className="text-gray-300 text-lg">
              Ready to discover your next favorite AI-generated track?
            </p>
          </div>
        </header>

        <div className="px-8 py-6">
          {generatedMusic.length > 0 && (
            <section className="mb-8">
              <h3 className="text-2xl font-bold text-white mb-6">
                Your Creations
              </h3>
              <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6 gap-4">
                {generatedMusic.map((music) => (
                  <Card
                    key={`generated-${music.id}`}
                    className="bg-gray-900/40 border-0 hover:bg-gray-800/60 transition-all duration-300 cursor-pointer group p-3 rounded-lg"
                    onClick={() => setSelectedAlbum(music)}
                  >
                    <div className="relative mb-3">
                      <img
                        src={music.coverUrl}
                        alt={music.title}
                        className="w-full aspect-square object-cover rounded-md shadow-lg"
                        onError={(e) => {
                          e.currentTarget.src =
                            "https://picsum.photos/400/400?random=1";
                        }}
                      />
                      <div className="absolute bottom-2 right-2 opacity-0 group-hover:opacity-100 transition-all duration-300 transform translate-y-2 group-hover:translate-y-0">
                        <Button
                          size="sm"
                          className="rounded-full w-10 h-10 bg-green-500 hover:bg-green-400 text-black shadow-lg p-0"
                        >
                          <Play className="w-4 h-4 ml-0.5" />
                        </Button>
                      </div>
                    </div>
                    <h4 className="font-semibold text-white mb-1 truncate text-sm">
                      {music.title}
                    </h4>
                    <p className="text-gray-400 text-xs truncate">
                      {music.artist}
                    </p>
                  </Card>
                ))}
              </div>
            </section>
          )}

          <section>
            <h3 className="text-2xl font-bold text-white mb-6">Made for you</h3>
            <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6 gap-4">
              {musicList.map((album) => (
                <Card
                  key={album.id}
                  className="bg-gray-900/40 border-0 hover:bg-gray-800/60 transition-all duration-300 cursor-pointer group p-3 rounded-lg"
                  onClick={() => setSelectedAlbum(album)}
                >
                  <div className="relative mb-3">
                    <img
                      src={album.coverUrl}
                      alt={album.title}
                      className="w-full aspect-square object-cover rounded-md shadow-lg"
                      onError={(e) => {
                        e.currentTarget.src =
                          "https://picsum.photos/400/400?random=1";
                      }}
                    />
                    <div className="absolute bottom-2 right-2 opacity-0 group-hover:opacity-100 transition-all duration-300 transform translate-y-2 group-hover:translate-y-0">
                      <Button
                        size="sm"
                        className="rounded-full w-10 h-10 bg-green-500 hover:bg-green-400 text-black shadow-lg p-0"
                      >
                        <Play className="w-4 h-4 ml-0.5" />
                      </Button>
                    </div>
                  </div>
                  <h4 className="font-semibold text-white mb-1 truncate text-sm">
                    {album.title}
                  </h4>
                  <p className="text-gray-400 text-xs truncate">
                    {album.artist}
                  </p>
                </Card>
              ))}
            </div>
          </section>
        </div>
      </main>

      <Dialog
        open={!!selectedAlbum}
        onOpenChange={() => setSelectedAlbum(null)}
      >
        <DialogContent className="bg-gray-900 border-gray-700 text-white max-w-md">
          <DialogHeader>
            <DialogTitle className="text-white">Now Playing</DialogTitle>
          </DialogHeader>
          {selectedAlbum && (
            <div className="space-y-4">
              <img
                src={selectedAlbum.coverUrl}
                alt={selectedAlbum.title}
                className="w-full aspect-square object-cover rounded-lg"
              />
              <div className="text-center">
                <h3 className="text-xl font-semibold text-white">
                  {selectedAlbum.title}
                </h3>
                <p className="text-gray-400">{selectedAlbum.artist}</p>
              </div>
              <div className="flex justify-center">
                <Button
                  onClick={handlePlayPause}
                  className="bg-green-500 hover:bg-green-400 text-black w-16 h-16 rounded-full p-0"
                >
                  {isPlaying ? (
                    <Pause className="w-6 h-6" />
                  ) : (
                    <Play className="w-6 h-6 ml-1" />
                  )}
                </Button>
              </div>
              <div className="space-y-2">
                <div className="relative">
                  <input
                    type="range"
                    min="0"
                    max={duration || 0}
                    value={currentTime}
                    onChange={handleSeek}
                    className="w-full h-2 bg-gray-600 rounded-lg appearance-none cursor-pointer slider"
                    style={{
                      background: `linear-gradient(to right, #22c55e 0%, #22c55e ${
                        (currentTime / (duration || 1)) * 100
                      }%, #4b5563 ${
                        (currentTime / (duration || 1)) * 100
                      }%, #4b5563 100%)`,
                    }}
                  />
                  <style>{`
                    .slider::-webkit-slider-thumb {
                      appearance: none;
                      width: 16px;
                      height: 16px;
                      border-radius: 50%;
                      background: #22c55e;
                      cursor: pointer;
                      border: 2px solid #22c55e;
                      box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
                    }
                    .slider::-moz-range-thumb {
                      width: 16px;
                      height: 16px;
                      border-radius: 50%;
                      background: #22c55e;
                      cursor: pointer;
                      border: 2px solid #22c55e;
                      box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
                    }
                  `}</style>
                </div>
                <div className="flex justify-between text-sm text-gray-400">
                  <span>{formatTime(currentTime)}</span>
                  <span>{formatTime(duration)}</span>
                </div>
              </div>
            </div>
          )}
        </DialogContent>
      </Dialog>
    </div>
  );
}

export default App;

image.png

まず、音楽プレーヤーに必要なステートを整理しましょう。

  const [selectedAlbum, setSelectedAlbum] = useState<Music | null>(null);
  const [isPlaying, setIsPlaying] = useState(false);
  const [currentTime, setCurrentTime] = useState(0);
  const [duration, setDuration] = useState(0);
  const [audio, setAudio] = useState<HTMLAudioElement | null>(null);

それぞれのステートの役割を解説します。

selectedAlbum: 現在選択されている音楽の情報を保持します。Music | nullという型定義により、音楽が選択されていない場合はnull、選択されている場合はMusic型のオブジェクトが入ります。

isPlaying: 音楽が再生中かどうかの状態を管理します。再生中ならtrue、停止中ならfalseです。

currentTime: 現在の再生位置(秒)を保持します。シークバーの表示や時間の表示に使用します。

duration: 音楽の総再生時間(秒)を保持します。音楽ファイルが読み込まれた時に設定されます。

audio: HTMLAudioElementのインスタンスを保持します。実際の音楽再生を制御するためのオブジェクトです。

音楽プレーヤーの核心部分であるhandlePlayPause関数を詳しく見ていきましょう。

  const handlePlayPause = () => {
    if (!selectedAlbum) return;

    if (!audio) {
      const newAudio = new Audio(selectedAlbum.audioUrl);
      newAudio.addEventListener("loadedmetadata", () => {
        setDuration(newAudio.duration);
      });
      newAudio.addEventListener("timeupdate", () => {
        setCurrentTime(newAudio.currentTime);
      });
      newAudio.addEventListener("ended", () => {
        setIsPlaying(false);
        setCurrentTime(0);
      });
      setAudio(newAudio);
      newAudio.play().catch(console.error);
      setIsPlaying(true);
    } else {
      if (isPlaying) {
        audio.pause();
        setIsPlaying(false);
      } else {
        audio.play().catch(console.error);
        setIsPlaying(true);
      }
    }
  };

HTMLAudioElementのインスタンスを作成します。この時点では音楽ファイルの読み込みが始まりますが、まだ再生は開始されません。

const newAudio = new Audio(selectedAlbum.audioUrl);

HTMLAudioElementでは音楽の状態変化を監視するために複数のイベントリスナーを設定します。
イベントリスナーとは「何かが起こったときに実行される関数」のことです。
例えば音楽が変わったときにはloadmetadataというイベントが発火します。このイベントが発火したときの挙動をここでは書いています。

loadedmetadataは音楽ファイルのメタデータ(再生時間など)が読み込まれた時に発火します。
音楽ファイルの総再生時間が取得できた時点でdurationステートを更新します。これによりシークバーの最大値が設定されます。

      newAudio.addEventListener("loadedmetadata", () => {
        setDuration(newAudio.duration);
      });

timeupdateは再生位置が変更された時に発火(通常は1秒間に4回程度)します。
再生が進むたびに現在の再生位置をcurrentTimeステートに保存します。これによりシークバーの位置や時間表示がリアルタイムで更新されます。

      newAudio.addEventListener("timeupdate", () => {
        setCurrentTime(newAudio.currentTime);
      });

endedは音楽の再生が終了した時に発火します。
音楽が最後まで再生された時に、再生状態をリセットして再生位置を0に戻します。

    newAudio.addEventListener("ended", () => {
        setIsPlaying(false);
        setCurrentTime(0);
      });

シークバーを使って再生位置を変更する機能を実装しています。

  const handleSeek = (e: React.ChangeEvent<HTMLInputElement>) => {
    if (audio) {
      const seekTime = parseFloat(e.target.value);
      audio.currentTime = seekTime;
      setCurrentTime(seekTime);
    }
  };

e.target.valueでスライダーの現在の値を取得し、parseFloatで数値に変換します。
その値をaudio.currentTimeに設定することで再生位置を変更できます。
同時にsetCurrentTimeでReactのステートも更新することで、UI表示と実際の再生位置を同期させています。

音楽プレーヤーでは「異なる音楽が選択されたとき」に前の音楽を停止して新しい音楽の準備をする必要があります。
そのためにuseEffectと依存配列を使います。

  // 異なる音楽が選択されたときに実行
  useEffect(() => {
    if (audio && selectedAlbum) {
      audio.pause();
      audio.currentTime = 0;
      setIsPlaying(false);
      setCurrentTime(0);
      setDuration(0);
      setAudio(null);
    }
  }, [selectedAlbum?.id]);

ここでReactの重要な概念であるuseEffectの依存配列について詳しく解説します。
これまでuseEffectは以下のように使ってきました

useEffect(() => {
  // 何かの処理
}, []); // この [] が依存配列

依存配列はどの値が変わったときにuseEffectを再実行するかを指定するものです。

1. 空の配列 []

コンポーネントが最初に表示される時に1回だけ実行されます。

useEffect(() => {
  console.log('一度だけ実行される');
}, []); // 依存配列が空

2. 特定の値を監視

countの値が変わったときのみ実行されます。

const [count, setCount] = useState(0);

useEffect(() => {
  console.log('countが変わったときに実行される');
}, [count]); // countを監視

今回私たちはselectedAlbum?.idを依存配列に入れました。
これは選択した音楽が変わったときに再度useEffectを実行するために設定しています。
?.はオプショナルチェーンと呼ばれるものでもし仮にselectedAlbumnullだとしてもエラーにならないようにしています。

もう一つのuseEffectはクリーンアップを行っています。
クリーンアップと別のページ(作成ページ)に言ったときに音楽が再生のままだとずっと音楽がなり続けてしまうのを防ぐために行っています。

  useEffect(() => {
    return () => {
      if (audio) {
        audio.pause();
        setAudio(null);
      }
    };
  }, [audio]);

別のページに遷移するとコンポーネントがアンマウント(画面から削除)されます。
そのタイミングでuseEffectが実行されるのでaudioがあれば止めることができます

また/createに遷移できるようにボタンも追加しました

            <Link to="/create">
              <Button className="bg-green-500 hover:bg-green-400 text-black font-semibold px-6 py-2 rounded-full">
                <Plus className="w-4 h-4 mr-2" />
                Generate Music
              </Button>

aタグでなくreact-routerの提供するLinkタグを使いました。
Linkタグを利用することでSPAらしい最適化された状態でページ遷移ができます。

10. 作成ページのスタイリング

npx shadcn@latest add input
npx shadcn@latest add label
npx shadcn@latest add textarea
npx shadcn@latest add select
src/CreatePage.tsx
import { useState } from "react";
import axios from "axios";
import { Button } from "./components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "./components/ui/card";
import { Textarea } from "./components/ui/textarea";
import { Input } from "./components/ui/input";
import { Label } from "./components/ui/label";
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from "./components/ui/select";
import { Music, Loader2 } from "lucide-react";

type Music = {
  id: string;
  title: string;
  artist: string;
  audioUrl: string;
  coverUrl: string;
};

function CreatePage() {
  const [title, setTitle] = useState("");
  const [genre, setGenre] = useState("");
  const [prompt, setPrompt] = useState("");
  const [generatedMusic, setGeneratedMusic] = useState("");
  const [isGenerating, setIsGenerating] = useState(false);

  const handleGenerate = async () => {
    if (!title.trim() || !genre || !prompt.trim()) {
      alert("すべてのフィールドを入力してください");
      return;
    }

    const apiKey = import.meta.env.VITE_LOUDLY_API_KEY;

    if (!apiKey) {
      alert("APIキーが設定されていません");
      return;
    }

    setIsGenerating(true);

    try {
      const formData = new FormData();
      const musicPrompt = `Create a ${genre} song titled "${title}". Musical style: ${prompt}. High quality production with clear melody and rhythm.`;
      formData.append("prompt", musicPrompt);
      formData.append("duration", "30");

      const response = await axios.post(
        "https://soundtracks.loudly.com/api/ai/prompt/songs",
        formData,
        {
          headers: {
            "API-KEY": apiKey,
          },
        }
      );

      if (response.data && response.data.music_file_path) {
        setGeneratedMusic(response.data.music_file_path);
      } else {
        throw new Error("音楽ファイルパスが取得できませんでした");
      }
    } catch (error) {
      console.error("エラー:", error);
      alert("音楽生成に失敗しました");
    } finally {
      setIsGenerating(false);
    }
  };

  const handleSave = () => {
    if (!generatedMusic || !title || !genre) {
      alert("音楽を生成してから保存してください");
      return;
    }

    const musicData: Music = {
      id: Date.now().toString(),
      title: title,
      artist: "AI Generated",
      audioUrl: generatedMusic,
      coverUrl: `https://picsum.photos/400/400?random=${Date.now()}`,
    };

    const savedMusic = JSON.parse(
      localStorage.getItem("generatedMusic") || "[]"
    );
    savedMusic.push(musicData);
    localStorage.setItem("generatedMusic", JSON.stringify(savedMusic));

    alert("音楽を保存しました!");
  };

  return (
    <div className="min-h-screen bg-black text-white">
      <div className="p-8">
        <header className="mb-8">
          <div className="flex items-center gap-4 mb-6">
            <h1 className="text-3xl font-bold">Generate Music</h1>
          </div>
        </header>

        <div className="grid grid-cols-1 lg:grid-cols-2 gap-8 max-w-6xl mx-auto">
          <Card className="bg-gray-900 border-gray-800">
            <CardHeader>
              <CardTitle className="text-white flex items-center gap-2">
                <Music className="w-5 h-5 text-green-500" />
                Music Generation
              </CardTitle>
            </CardHeader>
            <CardContent className="space-y-6">
              <div>
                <Label
                  htmlFor="title"
                  className="text-white text-sm font-medium"
                >
                  Song Title
                </Label>
                <Input
                  id="title"
                  value={title}
                  onChange={(e) => setTitle(e.target.value)}
                  placeholder="Enter song title..."
                  className="bg-gray-800 border-gray-700 text-white placeholder:text-gray-400 mt-2"
                />
              </div>

              <div>
                <Label
                  htmlFor="genre"
                  className="text-white text-sm font-medium"
                >
                  Genre
                </Label>
                <Select value={genre} onValueChange={setGenre}>
                  <SelectTrigger className="bg-gray-800 border-gray-700 text-white mt-2">
                    <SelectValue placeholder="Select genre..." />
                  </SelectTrigger>
                  <SelectContent className="bg-gray-800 border-gray-700">
                    <SelectItem value="electronic">Electronic</SelectItem>
                    <SelectItem value="jazz">Jazz</SelectItem>
                    <SelectItem value="classical">Classical</SelectItem>
                    <SelectItem value="ambient">Ambient</SelectItem>
                    <SelectItem value="rock">Rock</SelectItem>
                    <SelectItem value="pop">Pop</SelectItem>
                  </SelectContent>
                </Select>
              </div>

              <div>
                <Label
                  htmlFor="prompt"
                  className="text-white text-sm font-medium"
                >
                  Music Description
                </Label>
                <Textarea
                  id="prompt"
                  value={prompt}
                  onChange={(e) => setPrompt(e.target.value)}
                  placeholder="Describe the music you want to generate..."
                  className="bg-gray-800 border-gray-700 text-white placeholder:text-gray-400 min-h-[120px] mt-2"
                />
              </div>

              <Button
                onClick={handleGenerate}
                disabled={
                  !title.trim() || !genre || !prompt.trim() || isGenerating
                }
                className="w-full bg-green-500 hover:bg-green-600 text-black font-semibold py-3"
              >
                {isGenerating ? (
                  <>
                    <Loader2 className="w-4 h-4 mr-2 animate-spin" />
                    Generating...
                  </>
                ) : (
                  <>
                    <Music className="w-4 h-4 mr-2" />
                    Generate Music
                  </>
                )}
              </Button>
            </CardContent>
          </Card>

          <Card className="bg-gray-900 border-gray-800">
            <CardHeader>
              <CardTitle className="text-white">Preview</CardTitle>
            </CardHeader>
            <CardContent>
              {isGenerating ? (
                <div className="flex flex-col items-center justify-center h-80 space-y-4">
                  <Loader2 className="w-12 h-12 text-green-500 animate-spin" />
                  <p className="text-gray-400 text-center">
                    Generating your music...
                    <br />
                    <span className="text-sm">This may take a few moments</span>
                  </p>
                </div>
              ) : generatedMusic ? (
                <div className="space-y-6">
                  <div className="aspect-square w-full max-w-sm mx-auto">
                    <img
                      src={`https://picsum.photos/400/400?random=${Date.now()}`}
                      alt="Generated album cover"
                      className="w-full h-full object-cover rounded-lg shadow-lg"
                      onError={(e) => {
                        e.currentTarget.src =
                          "https://picsum.photos/400/400?random=1";
                      }}
                    />
                  </div>

                  <div className="text-center space-y-2">
                    <h3 className="text-xl font-semibold text-white">
                      {title || "Untitled"}
                    </h3>
                    <p className="text-gray-400 capitalize">
                      {genre ? `${genre} • AI Generated` : "AI Generated"}
                    </p>
                  </div>

                  <div className="space-y-4">
                    <audio
                      controls
                      className="w-full"
                      style={{
                        filter: "invert(1) hue-rotate(180deg)",
                      }}
                    >
                      <source src={generatedMusic} type="audio/mpeg" />
                      Your browser does not support the audio element.
                    </audio>

                    <Button
                      onClick={handleSave}
                      className="w-full bg-green-500 hover:bg-green-600 text-black font-semibold py-3"
                    >
                      Save to Collection
                    </Button>
                  </div>
                </div>
              ) : (
                <div className="flex flex-col items-center justify-center h-80 space-y-4">
                  <div className="w-32 h-32 bg-gray-800 rounded-lg flex items-center justify-center border-2 border-dashed border-gray-600">
                    <Music className="w-12 h-12 text-gray-600" />
                  </div>
                  <p className="text-gray-400 text-center max-w-xs">
                    Fill out the form and click generate to create your music
                  </p>
                </div>
              )}
            </CardContent>
          </Card>
        </div>
      </div>
    </div>
  );
}

export default CreatePage;

image.png

理解を深める課題

ここまでの内容をより理解するために以下の課題を行ってください

  1. Firebaseへのデプロイ
    ここまででアプリの作成の仕方を学んだのであとは公開すれば0->1を経験することができます。Firebaseを利用すると簡単に公開ができるのでチャレンジしてみてください

  2. 音楽生成ページからトップに戻れるようにする
    音楽を保存したらトップページに戻るように実装してみてください

  3. 生成した音楽のジャケットを固定する
    いまアルバムのジャケットがランダムになっているので、生成ページで設定できるように修正してください

おわりに

いかがでしたでしょうか?
今回はAIを活用しながら初心者が難しいと感じる非同期処理やuseEffectに関して具体的にお話ししました。

詳しく解説した動画を投稿しているのでよかったらみてみてください!

JISOUのメンバー募集中!

プログラミングコーチングJISOUでは、新たなメンバーを募集しています。
日本一のアウトプットコミュニティでキャリアアップしませんか?
興味のある方は、ぜひホームページからお気軽にカウンセリングをお申し込みください!
▼▼▼

図解ハンズオンたくさん投稿しています!

本チュートリアルのレビュアーの皆様

次回のハンズオンのレビュアーはXにて募集します。

  • 中嶋様
  • 山本様
  • ナツキ様
  • tokec様
  • ARISA様
  • fumiya様
  • 野沢様
  • 河野様
69
53
1

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
69
53

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?