Мы создадим автоматическую караоке-систему с помощью React и Vercel.
Демо караоке-системы
Проект будет включать распознавание песни по звуковому образцу и отображение ее текста для пения. Для распознавания звука воспользуемся ACRCloud, а Musixmatch предоставит тексты песен. Кроме того, понадобится серверный код для взаимодействия с этими сервисами. Поэтому мы также научимся, как с помощью Vercel настроить бессерверную функциональность, чтобы не создавать сервер самим.
Настройка проекта
Начнем с инициализации проекта Vite с React и TypeScript. Затем воспользуемся следующими пакетами NPM, чтобы собрать все и запустить:
npm i acrcloud formidable formidable-serverless jotai react-feather react-input-slider react-media-recorder
npm i -D @vercel/node @types/formidable
Распознавание песни
Использование API ACRCloud
Чтобы получить доступ к API ACRCloud, понадобятся учетные данные.
Создайте учетную запись на acrcloud.com и перейдите к разделу “Проекты распознавания аудио и видео”. В учетной записи бесплатного уровня можно работать только с одним проектом. Поэтому, если он у вас уже есть, используйте учетные данные оттуда. Если нет, создайте новый проект с предоставленной конфигурацией:
Убедитесь, что после выбора необходимого bucket
установлен флажок isrc
. Позже это пригодится.
Скопируйте хост, ключ доступа и секретный ключ, а затем вставьте их в файл .env
в корне проекта.
...
ACR_HOST="identify-eu-west-1.acrcloud.com"
ACR_ACCESS_KEY="INSERT_ACCESS_KEY"
ACR_SECRET="INSERT_SECRET_KEY"
Настройка бессерверности
Установите CLI Vercel командой npm i -g vercel
. Теперь запустите npx vercel link
, чтобы связать папку с проектом Vercel. Вы можете либо выбрать существующий проект, либо создать новый из командной строки.
Как только связь будет готова, создайте папку api
, где будут храниться отдельные файлы для каждой из бессерверных функций. Теперь создадим первую такую функцию для идентификации песни и возврата ее ISRC.
Создайте в этой папке новый файл с именем acr-identify.ts
, который будет содержать код, доступный для фронтенда по адресу http://localhost:[PORT]/api/acr-identify.
import type { VercelResponse, VercelRequest } from "@vercel/node";
import { readFile } from "fs/promises";
const acrcloud = require("acrcloud");
const formidable = require("formidable-serverless");
const acr = new acrcloud({
host: process.env.ACR_HOST as string,
access_key: process.env.ACR_ACCESS_KEY as string,
access_secret: process.env.ACR_SECRET as string,
data_type: "audio",
});
const form = formidable();
// Отдает в ответ ISRC музыки для загруженного сэмпла
export default async function (req: VercelRequest, res: VercelResponse) {
try {
// Оборачивает form.parse в Promise, чтобы перехватывать ошибки только с помощью одного блока try-catch
const { files } = await new Promise<{
fields: any;
files: { file: any };
}>((resolve, reject) =>
form.parse(
req,
async (
err: string | undefined,
fields: any,
files: { file: any }
) => {
if (err) {
reject(err);
}
resolve({ fields, files });
}
)
);
const file = files.file as any;
// Получает данные из загруженного файла
const fileData = await readFile(file.path);
const data = await acr.identify(fileData);
const metadata = data.metadata;
if (data.status.code !== 0) {
throw new Error(data.status.msg);
}
if (metadata.music?.length === 0) {
throw new Error("No music found");
}
const music = metadata.music[0];
res.status(200).json(music.external_ids.isrc);
} catch (err) {
res.status(500).send((err as Error).message);
}
}
Получение текста песни
Настройка Musixmatch
Как и в случае с ACRCloud, создайте учетную запись в Musixmatch Developers, если у вас ее еще нет. Как только вы создали приложение, перейдите на страницу Applications, чтобы найти API-ключ. Теперь добавьте этот ключ в файл .env
.
Создание функции
Воспользуемся axios
для API-запросов: сначала запросим у Musixmatch идентификатор трека для данного ISRC, затем запросим текст трека. Здесь применим тот же подход, что и раньше для создания функции, но теперь в /api/find-lyrics
. Добавьте в новый файл следующий код:
import type { VercelResponse, VercelRequest } from "@vercel/node";
import axios from "axios";
const BASE_URL = "https://api.musixmatch.com/ws/1.1/";
const baseParams = {
apikey: process.env.MUSIXMATCH_API_KEY as string,
format: "json",
};
// Находим слова указанной песни по ISRC
export default async function (req: VercelRequest, res: VercelResponse) {
try {
const { isrc } = req.query;
if (!isrc) {
res.status(400).send("Song ISRC required");
return;
}
let response = await axios.get(BASE_URL + "track.get", {
params: {
...baseParams,
track_isrc: isrc,
},
});
const track = response.data.message.body.track;
response = await axios.get(BASE_URL + "track.lyrics.get", {
params: {
...baseParams,
track_id: track.track_id,
},
});
const lyrics = response.data.message.body.lyrics.lyrics_body;
const title = `${track.track_name} by ${track.artist_name}`;
res.status(200).json({ title, lyrics });
} catch (err) {
res.status(500).send((err as Error).message);
}
}
Здесь не указан HTTP-метод, задействованный бессерверными функциями. Поэтому, предположив, что клиент делает GET-запрос, будем считывать параметры URL для получения ISRC.
Atom
Для управления состоянием React-приложения используем jotai
. Создайте файл /src/store.ts
и добавьте туда следующие конфигурации atom
:
import { atom } from "jotai";
// Хранит ошибки, возвращаемые бессерверными функциями
export const errorAtom = atom("");
// Хранит название песни
export const titleAtom = atom("");
// Измеряет слова в минуту
export const songRateAtom = atom(160);
// Показывает текст песни
export const lyricsAtom = atom("");
export const currentWordAtom = atom(0);
// Позволяет изменить индекс следующего слова, которое нужно подсветить
export const newWordAtom = atom(-1);
// Атом, доступный только для записи, обновляет индекс выделенного слова
export const nextWordAtom = atom(null, (get, set) => {
// Получает каждое слово из текста песни
const words = [...get(lyricsAtom).matchAll(/[\w']+/g)].map(
(match) => match[0]
);
set(currentWordAtom, (prev) => {
// Переопределяет индекс нового слова значением из `newWordAtom`
const newWord = get(newWordAtom);
if (newWord !== -1) {
set(newWordAtom, -1);
return newWord;
}
return (prev + 1) % words.length;
});
});
Создание компонентов приложения
Если в ответе не возвращается текст песни, мы показываем пользователю кнопку микрофона. Если наоборот, отображается текст песни и сопутствующая информация. Поместите следующий код в /src/App.tsx
:
import React from "react";
import { useAtomValue } from "jotai";
import LyricsBody from "./lyrics/LyricsBody";
import MicrophoneInput from "./MicrophoneInput";
import SongTitle from "./SongTitle";
import { lyricsAtom } from "./store";
import SongRateInput from "./SongRateInput";
import ErrorMessage from "./ErrorMessage";
export default function App() {
const lyrics = useAtomValue(lyricsAtom);
const renderedChild =
lyrics === "" ? (
<MicrophoneInput />
) : (
<>
<SongTitle />
<LyricsBody />
<SongRateInput />
</>
);
return (
<main className="w-full h-full px-10 flex flex-col justify-center items-center">
<h1 className="font-bold font-sans text-6xl">KaraokeNow</h1>
<p className="mb-5">React ⚛️ + Vite ⚡ + Replit 🌀</p>
{renderedChild}
<ErrorMessage />
</main>
);
}
Обратите внимание, что в коде используется скорость воспроизведения. Позже мы увидим, как выделить слова в тексте песни, чтобы пользователи могли следить за ними.
Показ ошибок
Ошибки в автоматической караоке-системе чаще всего будут происходит из-за того, что не найдена сама песня или текст песни. Однако мы можем предоставить пользователю обратную связь через еще один компонент — /src/ErrorMessage.tsx
.
import React from "react";
import { errorAtom } from "./store";
import { useAtomValue } from "jotai";
export default function ErrorMessage() {
const error = useAtomValue(errorAtom);
if (!error) return null;
return (
<div className="bg-red-500 px-6 py-4 m-7 h-24 flex overflow-auto rounded-2xl">
<p className="font-mono my-auto w-80 whitespace-nowrap text-center">
{error}
</p>
</div>
);
}
Запись аудио
Создадим новый компонент MicrophoneInput
в /src/MicrophoneInput.tsx
. С помощью react-media-recorder
будем записывать аудио определенной длины и отправлять сэмпл в бессерверную функцию.
Структура файла
Начнем с того, что зададим структуру файла. Вот как это выглядит:
`import React, { useEffect, useState } from "react";
import { useReactMediaRecorder } from "react-media-recorder";
import { Mic, Activity } from "react-feather";
import axios, { AxiosError } from "axios";
import { useSetAtom } from "jotai";
import { errorAtom, lyricsAtom, titleAtom } from "./store";
const RECORD_DURATION = 15000;
export default function MicrophoneInput() {
// ...
return (
<button type="button">
Implement me
</button>
);
}
Использование хуков
Нам понадобятся функции для обновления состояния при взаимодействии с бессерверными функциями.
// ...
const setError = useSetAtom(errorAtom);
const setLyrics = useSetAtom(lyricsAtom);
const setTitle = useSetAtom(titleAtom);
// ...
react-media-recorder
предоставляет хуки для упрощения записи мультимедиа в React. Здесь мы добавляем обработчик события остановки, который отправляет аудио.
const { status, startRecording, stopRecording } = useReactMediaRecorder({
audio: true,
onStop: (_url, blob) => {
onSubmit(blob);
},
});
Начало записи
Запись начинается при нажатии кнопки и останавливается по истечении миллисекунд в RECORD_DURATION
.
const onClick = async () => {
if (status === "recording") {
return;
}
// Очищает ошибку, когда запись начинается заново
setError("");
await startRecording();
// Запускает анимацию старта
setEndTime(Date.now() + RECORD_DURATION);
await new Promise((resolve) => {
setTimeout(resolve, RECORD_DURATION);
});
stopRecording();
};
Анимация хода записи
Для обновления анимации прогресса нужно воспользоваться requestAnimationFrame
. Из приведенного выше кода видно, что анимация показывает полосу, высота которой уменьшается в процессе записи.
useEffect(() => {
if (endTime === 0) return;
const request = window.requestAnimationFrame(updateProgress);
return () => {
window.cancelAnimationFrame(request);
};
}, [endTime]);
Эта функция, в свою очередь, обновляет прогресс анимации:
const updateProgress = () => {
// прошедшее время / продолжительность
const recorded = Math.max(0, endTime - Date.now()) / RECORD_DURATION;
// сбрасывает высоту по завершении
setProgressHeight(recorded === 0 ? 100 : recorded * 100);
window.requestAnimationFrame(updateProgress);
};
Отправка аудиоданных
Теперь мы можем создать функцию для отправки аудиофайла.
const onSubmit = async (blob: Blob) => {
const body = new FormData();
body.append("file", blob);
try {
let response = await axios.post("/api/acr-identify", body);
const trackISRC = response.data;
response = await axios.get("/api/find-lyrics", {
params: { isrc: trackISRC },
});
const { title, lyrics } = response.data;
setTitle(title);
setLyrics(lyrics);
} catch (err) {
let message: string;
if (err instanceof AxiosError) {
message = err.response?.data;
} else {
message = (err as Error).message;
}
setError(message);
}
};
Рендеринг анимации
Отрисуем кнопку, которая заключает весь запланированный код:
return (
<button
type="button"
className="bg-indigo-300 rounded-2xl border-gray-300 border-2 p-7 relative"
onClick={onClick}
disabled={status === "recording"}
>
<div
style={{
height: `${progressHeight}%`,
}}
className="absolute rounded-2xl w-full bg-indigo-500 bottom-0 left-0"
/>
<Icon className="relative z-10 w-12 h-12" />
</button>
);
Показ названия песни
В /src/SongTitle.tsx
создайте компонент для отображения названия распознанной песни.
import React from "react";
import { useAtomValue } from "jotai";
import { titleAtom } from "./store";
export default function SongTitle() {
const title = useAtomValue(titleAtom);
return (
<div
title={title}
className="px-4 py-2 bg-gray-400/60 rounded-3xl shadow-md mb-2"
>
<h4 className="font-bold text-xl">{title}</h4>
</div>
);
}
Изменение скорости песни
Для создания компонента слайдера, который управляет скоростью воспроизведения песни, будет задействован react-input-slider
. На деле не все тексты поются с одинаковой скоростью, но пользователь все же сможет контролировать, насколько быстро он хочет петь.
Если вы заинтересованы в более точном отслеживании текстов песен, ознакомьтесь с Rich sync от Musixmatch.
Сейчас же мы создадим файл /src/Song Rate Input.tax
и добавим следующий код компонента:
import React from "react";
import { useAtom } from "jotai";
import { songRateAtom } from "./store";
import Slider from "react-input-slider";
export default function SongRateInput() {
const [rate, setRate] = useAtom(songRateAtom);
const onChange = ({ x }: { x: number }) => setRate(x);
return (
<div className="max-w-2xl w-full absolute bottom-8">
<Slider
axis="x"
x={rate}
xmin={100}
xmax={250}
onChange={onChange}
styles={{
track: {
width: "100%",
},
}}
/>
<p className="text-center">Song Rate (WPM)</p>
</div>
);
}
Показ текста песни
Чтобы человек мог поспевать за текстом песни, каждое слово нужно выделять отдельно и последовательно, как показано в демоверсии.
Обновление текущего слова
Мы можем создать папку с именем lyrics
внутри src
. Первым файлом из трех здесь будет LyrixBody.tsx
. Этот компонент отвечает за отображение всех слов текста песни и обновление текущего выделенного слова.
import React, { useEffect, useMemo } from "react";
import { useAtomValue, useSetAtom } from "jotai";
import { lyricsAtom, nextWordAtom, songRateAtom } from "../store";
import parseLyrics from "./parser";
export default function LyricsBody() {
const lyrics = useAtomValue(lyricsAtom);
const nextWord = useSetAtom(nextWordAtom);
const songRate = useAtomValue(songRateAtom);
const segments = useMemo(() => parseLyrics(lyrics), [lyrics]);
useEffect(() => {
let request: number;
//Преобразует количество слов в минуту в миллисекунды на слово
const delay = (60 * 1000) / songRate;
let start: number;
let previousTime: number;
const animateWords = (time: number) => {
if (!start) {
start = time;
previousTime = time;
}
// Если с момента выделения последнего слова прошло хотя бы "delay" миллисекунд
if (time - previousTime >= delay) {
nextWord();
previousTime = time;
}
request = window.requestAnimationFrame(animateWords);
};
request = window.requestAnimationFrame(animateWords);
return () => {
window.cancelAnimationFrame(request);
};
}, [songRate]);
return (
<div className="border-dashed border-4 border-slate-400 rounded-3xl p-2 w-full">
<p className="text-3xl font-serif leading-loose h-80 overflow-y-auto text-center whitespace-pre-wrap">
{segments}
</p>
</div>
);
}
Чтобы обновление цвета, которым выделено конкретное слово, происходило более плавно, снова используем requestAnimationFrame
.
Обратите внимание на разбиение текстов песен на сегменты перед их рендерингом. Этот шаг важен для индивидуального оформления каждого слова.
Разбиение текстов песен
Создайте файл parser.tsx
в папке lyrics
. Не забывайте про расширение .tsx
, чтобы JSX-компоненты были доступны.
Этот файл экспортирует показанную ниже функцию для разбиения текста на компоненты (отдельные слова):
import React from "react";
import LyricWord from "./LyricWord";
export default function parseLyrics(lyrics: string) {
// Сопоставляет с шаблоном все слова в тексте, включая слова с апострофами
const matches = [...lyrics.matchAll(/[\w']+/g)];
// Производный массив для поиска совпадений по индексу начальной буквы
const matchStarts = matches.map((match) => match.index);
const nodes = [];
let i = 0;
let letter: string;
while (i < lyrics.length) {
letter = lyrics[i];
//Находит совпадающий индекс по индексу начальной буквы
const matchIndex = matchStarts.indexOf(i);
if (matchIndex !== -1) {
const match = matches[matchIndex];
const word = match[0];
// Индекс соответствия - это индекс слова
nodes.push(<LyricWord key={i} index={matchIndex} word={word} />);
// Перепрыгивает к символу после слова
i += word.length;
continue;
} else if (letter === "\n") {
// Заменяет символы начала строки на <br>
nodes.push(<br key={i} />);
} else {
nodes.push(letter);
}
i++;
}
return nodes;
}
Показ текста пословно
Создайте новый компонент в /src/lyrics/Lyric Word.tsx
. Для этого требуется индекс слова и значение самого слова.
Затем имена классов применяются к отображаемому <span>
, если индекс совпадает с индексом текущего слова.
import React, { useEffect, useRef } from "react";
import { useAtomValue, useSetAtom } from "jotai";
import { currentWordAtom, newWordAtom } from "../store";
interface LyricWordProps {
index: number;
word: string;
}
export default function LyricWord({ index, word }: LyricWordProps) {
const elementRef = useRef<HTMLSpanElement | null>(null);
const currentWord = useAtomValue(currentWordAtom);
const setNewWord = useSetAtom(newWordAtom);
useEffect(() => {
if (index !== currentWord || elementRef.current === null) return;
// Скроллит, пока не покажется выделенное слово
elementRef.current.scrollIntoView();
}, [currentWord]);
// Переопределяет значение для текущего слова при нажатии
const onClick = () => {
setNewWord(index);
};
let highlight: string;
const distanceToCurrent = Math.abs(index - currentWord);
if (index === currentWord) {
highlight = "text-purple-800 font-bold border-2 p-2 rounded-xl";
} else if (distanceToCurrent == 1) {
highlight = "text-purple-400";
} else {
highlight = "text-gray-700";
}
return (
<span
ref={elementRef}
className={`cursor-pointer translate-y-20 ${highlight}`}
onClick={onClick}
title="Skip here"
>
{word}
</span>
);
}
Заключение
Мы воспользовались бессерверной функциональностью Vercel для идентификации музыки и поиска текстов песен. Затем мы создали интерфейс, который последовательно выделяет слова песни и связывает все необходимое вместе.
При запуске проекта воспользуйтесь командой npx vercel dev
, чтобы убедиться, что бессерверные функции доступны.
Кроме того, обратите внимание, что тексты песен, возвращенные Musixmatch, неполные. Бесплатный показ включает только 30% текста каждой песни, а полный доступ возможен только через корпоративную подписку.
Альтернативу Musixmatch может предоставить genius.com, поскольку у них есть API, который возвращает URL-адрес с текстом песни. Потребуется лишь получить доступ к API и извлечь тексты песен.
Весь код доступен здесь.
Читайте также:
- 12 хуков React, которые должен знать каждый разработчик
- 9 проектов, которые помогут стать фронтенд-мастером в 2023 году
- Geist UI: Утонченная эстетика UI в React
Читайте нас в Telegram, VK и Дзен
Перевод статьи Joseph Nma: Build an Auto Karaoke System With React and Vercel