Мы создадим автоматическую караоке-систему с помощью 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 и извлечь тексты песен.

Весь код доступен здесь.

Читайте также:

Читайте нас в TelegramVK и Дзен


Перевод статьи Joseph Nma: Build an Auto Karaoke System With React and Vercel

Предыдущая статьяКонвейер данных в реальном времени с Kafka и ClickHouse
Следующая статьяCodeGPT: расширение VSCode с функциями ChatGPT