feat: 支持歌词生成
This commit is contained in:
parent
ebc1672611
commit
00041cc3f4
190
app/danmu/page.tsx
Normal file
190
app/danmu/page.tsx
Normal file
@ -0,0 +1,190 @@
|
||||
/* eslint-disable no-console -- debug */
|
||||
"use client";
|
||||
import { useRef, useState, useEffect } from "react";
|
||||
import { query } from "@/app/lib/actions/lyrics";
|
||||
|
||||
const useLyricsRunner = (runner: (lyric: string) => void, interval: number) => {
|
||||
const [lyrics, setLyrics] = useState<string[]>([]);
|
||||
const [offset, setOffset] = useState<number>(0);
|
||||
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const startTime = useRef<number>(Date.now());
|
||||
const ref = useRef<{ lyrics: string[]; offset: number }>({ lyrics, offset });
|
||||
|
||||
const clearTimer = (): void => {
|
||||
if (timerRef.current) {
|
||||
clearInterval(timerRef.current);
|
||||
}
|
||||
};
|
||||
|
||||
const doLyric = (): void => {
|
||||
console.log("testing ...", ref.current.lyrics.length);
|
||||
if (ref.current.lyrics.length > 0) {
|
||||
const currentTime = Date.now() - startTime.current;
|
||||
const found = ref.current.lyrics.find((lyric) => {
|
||||
const m = Number(lyric.slice(1, 3));
|
||||
const s = Number(lyric.slice(4, 6));
|
||||
const ms10 = Number(lyric.slice(7, 9));
|
||||
const ms = (m * 60 + s) * 1000 + ms10 * 10;
|
||||
return (
|
||||
currentTime > ms + ref.current.offset &&
|
||||
ms + ref.current.offset > currentTime - interval
|
||||
);
|
||||
});
|
||||
if (found) {
|
||||
runner(found);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
ref.current.offset = offset;
|
||||
ref.current.lyrics = lyrics;
|
||||
}, [offset, lyrics]);
|
||||
|
||||
const start = (): void => {
|
||||
clearTimer();
|
||||
startTime.current = Date.now();
|
||||
timerRef.current = setInterval(doLyric, interval);
|
||||
};
|
||||
const pause = (): void => {
|
||||
clearTimer();
|
||||
};
|
||||
const resume = (): void => {
|
||||
clearTimer();
|
||||
timerRef.current = setInterval(doLyric, interval);
|
||||
};
|
||||
return {
|
||||
offset,
|
||||
setOffset,
|
||||
setLyrics,
|
||||
resume,
|
||||
start,
|
||||
pause,
|
||||
};
|
||||
};
|
||||
|
||||
interface LyricItem {
|
||||
timestamp: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export default function Page(): JSX.Element {
|
||||
const ref = useRef<HTMLDialogElement | null>(null);
|
||||
const [history, setHistory] = useState<LyricItem[]>([]);
|
||||
|
||||
const { start, resume, pause, setLyrics, offset, setOffset } =
|
||||
useLyricsRunner((line) => {
|
||||
setHistory((items) => [
|
||||
{
|
||||
content: line.slice(0, 10),
|
||||
timestamp: line.slice(10),
|
||||
},
|
||||
...items,
|
||||
]);
|
||||
console.log(line);
|
||||
}, 200);
|
||||
|
||||
return (
|
||||
<div className="mockup-browser border">
|
||||
<div className="mockup-browser-toolbar">
|
||||
<div className="input">红豆</div>
|
||||
</div>
|
||||
<div className="flex justify-center px-4 py-16 border-t border-base-300">
|
||||
<div className="columns-1">
|
||||
<div className="w-full">
|
||||
<div className="join">
|
||||
<div className="stat join-item">延迟 {offset / 1000} 秒</div>
|
||||
<button
|
||||
className="btn join-item"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
pause();
|
||||
}}
|
||||
>
|
||||
暂停输出
|
||||
</button>
|
||||
<button
|
||||
className="btn join-item"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
resume();
|
||||
}}
|
||||
>
|
||||
继续输出
|
||||
</button>
|
||||
<button
|
||||
className="btn join-item"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setOffset(offset + 1000);
|
||||
}}
|
||||
>
|
||||
+1秒
|
||||
</button>
|
||||
<button
|
||||
className="btn join-item"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setOffset(offset - 1000);
|
||||
}}
|
||||
>
|
||||
-1秒
|
||||
</button>
|
||||
<button
|
||||
className="btn join-item"
|
||||
onClick={() => {
|
||||
query({
|
||||
name: "红豆",
|
||||
})
|
||||
.then((result) => {
|
||||
// eslint-disable-next-line no-console -- temp
|
||||
console.log(result);
|
||||
|
||||
if (result) {
|
||||
setLyrics(result.lyrics.split("\n"));
|
||||
start();
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
// eslint-disable-next-line no-console -- temp
|
||||
console.error(err);
|
||||
});
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
开始
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full mt-8">
|
||||
<ul className="timeline timeline-vertical">
|
||||
{history.map((item, i) => (
|
||||
<li key={i}>
|
||||
<div className="timeline-start">{item.timestamp}</div>
|
||||
<div className="timeline-middle">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
className="w-5 h-5"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="timeline-end timeline-box">
|
||||
{item.content}
|
||||
</div>
|
||||
<hr />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
227
app/lib/actions/lyrics.ts
Normal file
227
app/lib/actions/lyrics.ts
Normal file
@ -0,0 +1,227 @@
|
||||
"use server";
|
||||
|
||||
import Fuse, { type IFuseOptions } from "fuse.js";
|
||||
import axios, { type AxiosResponse } from "axios";
|
||||
|
||||
const SEARCH_URL = "https://music.163.com/api/search/get";
|
||||
const LYRICS_URL = "https://music.163.com/api/song/lyric";
|
||||
|
||||
enum LyricSource {
|
||||
GENIUS = "Genius",
|
||||
LRCLIB = "lrclib.net",
|
||||
NETEASE = "NetEase",
|
||||
}
|
||||
|
||||
interface InternetProviderLyricSearchResponse {
|
||||
artist: string;
|
||||
id: string;
|
||||
name: string;
|
||||
score?: number;
|
||||
source: LyricSource;
|
||||
}
|
||||
|
||||
interface InternetProviderLyricResponse {
|
||||
artist: string;
|
||||
id: string;
|
||||
lyrics: string;
|
||||
name: string;
|
||||
source: LyricSource;
|
||||
}
|
||||
|
||||
interface LyricSearchQuery {
|
||||
album?: string;
|
||||
artist?: string;
|
||||
duration?: number;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
interface NetEaseResponse {
|
||||
code: number;
|
||||
result: Result;
|
||||
}
|
||||
|
||||
interface Result {
|
||||
hasMore: boolean;
|
||||
songCount: number;
|
||||
songs: Song[];
|
||||
}
|
||||
|
||||
interface Song {
|
||||
album: Album;
|
||||
alias: string[];
|
||||
artists: Artist[];
|
||||
copyrightId: number;
|
||||
duration: number;
|
||||
fee: number;
|
||||
ftype: number;
|
||||
id: number;
|
||||
mark: number;
|
||||
mvid: number;
|
||||
name: string;
|
||||
rUrl: null;
|
||||
rtype: number;
|
||||
status: number;
|
||||
transNames?: string[];
|
||||
}
|
||||
|
||||
interface Album {
|
||||
artist: Artist;
|
||||
copyrightId: number;
|
||||
id: number;
|
||||
mark: number;
|
||||
name: string;
|
||||
picId: number;
|
||||
publishTime: number;
|
||||
size: number;
|
||||
status: number;
|
||||
transNames?: string[];
|
||||
}
|
||||
|
||||
interface Artist {
|
||||
albumSize: number;
|
||||
alias: any[];
|
||||
fansGroup: null;
|
||||
id: number;
|
||||
img1v1: number;
|
||||
img1v1Url: string;
|
||||
name: string;
|
||||
picId: number;
|
||||
picUrl: null;
|
||||
trans: null;
|
||||
}
|
||||
|
||||
export async function getSearchResults(
|
||||
params: LyricSearchQuery
|
||||
): Promise<InternetProviderLyricSearchResponse[] | null> {
|
||||
let result: AxiosResponse<NetEaseResponse>;
|
||||
|
||||
const searchQuery = [params.artist, params.name].join(" ");
|
||||
|
||||
if (!searchQuery) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
result = await axios.get(SEARCH_URL, {
|
||||
params: {
|
||||
limit: 5,
|
||||
offset: 0,
|
||||
s: searchQuery,
|
||||
type: "1",
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("NetEase search request got an error!", e);
|
||||
return null;
|
||||
}
|
||||
|
||||
const rawSongsResult = result?.data.result?.songs;
|
||||
|
||||
if (!rawSongsResult) return null;
|
||||
|
||||
const songResults: InternetProviderLyricSearchResponse[] = rawSongsResult.map(
|
||||
(song) => {
|
||||
const artist = song.artists
|
||||
? song.artists.map((artist) => artist.name).join(", ")
|
||||
: "";
|
||||
|
||||
return {
|
||||
artist,
|
||||
id: String(song.id),
|
||||
name: song.name,
|
||||
source: LyricSource.NETEASE,
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
return orderSearchResults({ params, results: songResults });
|
||||
}
|
||||
|
||||
async function getMatchedLyrics(
|
||||
params: LyricSearchQuery
|
||||
): Promise<Omit<InternetProviderLyricResponse, "lyrics"> | null> {
|
||||
const results = await getSearchResults(params);
|
||||
|
||||
const firstMatch = results?.[0];
|
||||
|
||||
if (!firstMatch || (firstMatch?.score && firstMatch.score > 0.5)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return firstMatch;
|
||||
}
|
||||
|
||||
export async function getLyricsBySongId(
|
||||
songId: string
|
||||
): Promise<string | null> {
|
||||
let result: AxiosResponse<any, any>;
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- debug
|
||||
result = await axios.get(LYRICS_URL, {
|
||||
params: {
|
||||
id: songId,
|
||||
kv: "-1",
|
||||
lv: "-1",
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("NetEase lyrics request got an error!", e);
|
||||
return null;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-return -- ddd
|
||||
return result.data.klyric?.lyric || result.data.lrc?.lyric;
|
||||
}
|
||||
|
||||
export async function query(
|
||||
params: LyricSearchQuery
|
||||
): Promise<InternetProviderLyricResponse | null> {
|
||||
const lyricsMatch = await getMatchedLyrics(params);
|
||||
if (!lyricsMatch) {
|
||||
console.error("Could not find the song on NetEase!");
|
||||
return null;
|
||||
}
|
||||
|
||||
const lyrics = await getLyricsBySongId(lyricsMatch.id);
|
||||
if (!lyrics) {
|
||||
console.error("Could not get lyrics on NetEase!");
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
artist: lyricsMatch.artist,
|
||||
id: lyricsMatch.id,
|
||||
lyrics,
|
||||
name: lyricsMatch.name,
|
||||
source: LyricSource.NETEASE,
|
||||
};
|
||||
}
|
||||
|
||||
const orderSearchResults = (args: {
|
||||
params: LyricSearchQuery;
|
||||
results: InternetProviderLyricSearchResponse[];
|
||||
}): InternetProviderLyricSearchResponse[] => {
|
||||
const { params, results } = args;
|
||||
|
||||
const options: IFuseOptions<InternetProviderLyricSearchResponse> = {
|
||||
fieldNormWeight: 1,
|
||||
includeScore: true,
|
||||
keys: [
|
||||
{ getFn: (song) => song.name, name: "name", weight: 3 },
|
||||
{ getFn: (song) => song.artist, name: "artist" },
|
||||
],
|
||||
threshold: 1.0,
|
||||
};
|
||||
|
||||
const fuse = new Fuse(results, options);
|
||||
|
||||
const searchResults = fuse.search<InternetProviderLyricSearchResponse>({
|
||||
...(params.artist && { artist: params.artist }),
|
||||
...(params.name && { name: params.name }),
|
||||
});
|
||||
|
||||
return searchResults.map((result) => ({
|
||||
...result.item,
|
||||
score: result.score,
|
||||
}));
|
||||
};
|
@ -23,9 +23,9 @@ export default function Home() {
|
||||
>
|
||||
弹幕测试
|
||||
</Link>
|
||||
<button className="ml-4 inline-flex text-gray-700 bg-gray-100 border-0 py-2 px-6 focus:outline-none hover:bg-gray-200 rounded text-lg">
|
||||
<Link href="/danmu" className="ml-4 inline-flex text-gray-700 bg-gray-100 border-0 py-2 px-6 focus:outline-none hover:bg-gray-200 rounded text-lg">
|
||||
歌词曲库
|
||||
</button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<div className="lg:max-w-lg lg:w-full md:w-1/2 w-5/6">
|
||||
|
Loading…
Reference in New Issue
Block a user