[O] Better lyrics

This commit is contained in:
2025-11-21 02:37:08 +08:00
parent 10ba316e3e
commit e5d6a8ae20
6 changed files with 42 additions and 19 deletions
+5
View File
@@ -9,6 +9,7 @@
"@iconify-json/material-symbols": "^1.2.46",
"@iconify-json/svg-spinners": "^1.2.4",
"@neteasecloudmusicapienhanced/api": "^4.29.17",
"@openrouter/sdk": "^0.1.17",
"@unocss/core": "^66.5.6",
"@unocss/extractor-svelte": "^66.5.6",
"@unocss/preset-attributify": "^66.5.6",
@@ -184,6 +185,8 @@
"@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="],
"@openrouter/sdk": ["@openrouter/sdk@0.1.17", "", { "dependencies": { "zod": "^3.25.0 || ^4.0.0" }, "peerDependencies": { "@tanstack/react-query": "^5", "react": "^18 || ^19", "react-dom": "^18 || ^19" }, "optionalPeers": ["@tanstack/react-query", "react", "react-dom"] }, "sha512-RFN0sfe83G85MirfpkZSuoX8hLLucemnwqrTr53vlrJmBJZ244CCnuZ33vpVUI8rLg+hP1i/smW6IExzYRDGDg=="],
"@parcel/watcher": ["@parcel/watcher@2.5.1", "", { "dependencies": { "detect-libc": "^1.0.3", "is-glob": "^4.0.3", "micromatch": "^4.0.5", "node-addon-api": "^7.0.0" }, "optionalDependencies": { "@parcel/watcher-android-arm64": "2.5.1", "@parcel/watcher-darwin-arm64": "2.5.1", "@parcel/watcher-darwin-x64": "2.5.1", "@parcel/watcher-freebsd-x64": "2.5.1", "@parcel/watcher-linux-arm-glibc": "2.5.1", "@parcel/watcher-linux-arm-musl": "2.5.1", "@parcel/watcher-linux-arm64-glibc": "2.5.1", "@parcel/watcher-linux-arm64-musl": "2.5.1", "@parcel/watcher-linux-x64-glibc": "2.5.1", "@parcel/watcher-linux-x64-musl": "2.5.1", "@parcel/watcher-win32-arm64": "2.5.1", "@parcel/watcher-win32-ia32": "2.5.1", "@parcel/watcher-win32-x64": "2.5.1" } }, "sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg=="],
"@parcel/watcher-android-arm64": ["@parcel/watcher-android-arm64@2.5.1", "", { "os": "android", "cpu": "arm64" }, "sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA=="],
@@ -1074,6 +1077,8 @@
"zimmerframe": ["zimmerframe@1.1.4", "", {}, "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ=="],
"zod": ["zod@4.1.12", "", {}, "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ=="],
"@babel/generator/@babel/parser": ["@babel/parser@7.28.5", "", { "dependencies": { "@babel/types": "^7.28.5" }, "bin": "./bin/babel-parser.js" }, "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ=="],
"@babel/traverse/globals": ["globals@11.12.0", "", {}, "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA=="],
+1
View File
@@ -37,6 +37,7 @@
"@iconify-json/material-symbols": "^1.2.46",
"@iconify-json/svg-spinners": "^1.2.4",
"@neteasecloudmusicapienhanced/api": "^4.29.17",
"@openrouter/sdk": "^0.1.17",
"@unocss/core": "^66.5.6",
"@unocss/extractor-svelte": "^66.5.6",
"@unocss/preset-attributify": "^66.5.6",
+27 -14
View File
@@ -1,12 +1,16 @@
import OpenAI from 'openai'
import { OpenRouter } from '@openrouter/sdk'
import type { LyricLine, LyricSegment } from '../../../shared/types'
import { isKanji } from 'wanakana'
import { isKana, isKanji } from 'wanakana'
// Please put OPENAI_API_KEY in your environment variables.
const client = new OpenAI()
// Please put OPENROUTER_API_KEY in your environment variables.
if (!process.env.OPENROUTER_API_KEY) throw new Error('Please set OPENROUTER_API_KEY in your environment variables.')
const client = new OpenRouter({
apiKey: process.env.OPENROUTER_API_KEY!
})
const req = {
// model: "gpt-5.1-chat-latest",
model: "gpt-4.1",
model: "openai/gpt-5",
// model: "gpt-4.1",
// model: 'x-ai/grok-4.1-fast:free',
messages: [
{
role: "system",
@@ -32,6 +36,9 @@ const req = {
2. **ふりがな(漢字)**: 歌詞本文では、**全ての漢字**に \`漢字(ふりがな)\` の形式でふりがな(ルビ)を付けてください。
3. **読み仮名(非日本語)**: 英語などの非日本語の単語には、\`Word(カタカナ読み)\` の形式でカタカナの読み仮名を付けてください。(例: \`Good(グッド)\`)。
4. **その他**: ひらがな、カタカナ、句読点などはそのまま出力してください。
5. **冗長なふりがなの禁止と混在処理(重要)**:
* **カタカナ・ひらがなのみの単語**: 元からカタカナやひらがなで表記されている単語には、ふりがなを付けないでください(例: \`アニメ\` はそのまま \`アニメ\` とし、\`アニメ(アニメ)\` としない)。
* **漢字とかなの混在**: \`のど飴\` のように漢字とかなが混ざっている表現は、全体を括るのではなく、**漢字部分にのみ**ふりがなを付けてください(例: \`のど 飴(あめ)\`)。 \`のど飴(のどあめ)\` のように、かな部分を含めてふりがなを振ることは禁止です。
重要
* **全ての漢字および非日本語単語に読み仮名を付けてください。** 読み方が不明な場合でも、文脈から最も一般的だと思われる読み方を付けてください。漢字をそのまま残してはいけません。
@@ -156,10 +163,11 @@ function parseFuriganaText(text: string): LyricLine[] {
while ((match = tokenRegex.exec(lyricText)) !== null) {
const [, kanji, furigana, plain] = match
if (kanji && furigana) {
if (kanji && kanji == furigana) lyric.push(kanji)
else if (kanji && furigana) {
let splitIndex = -1
for (let i = kanji.length - 1; i >= 0; i--) {
if (!isKanji(kanji[i]) && !('abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890'.includes(kanji[i]))) {
if (!isKanji(kanji[i]) && !isKana(kanji[i]) && !('abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890'.includes(kanji[i]))) {
splitIndex = i
break
}
@@ -191,14 +199,14 @@ export async function aiParseLyricsRaw(raw: string, tries: number = 5): Promise<
const thisReq = JSON.parse(JSON.stringify(req))
thisReq.messages[thisReq.messages.length - 1].content = raw
const response = await client.chat.completions.create(thisReq)
const text = response.choices[0].message?.content || ''
const response = await client.chat.send(thisReq)
const text = response.choices[0].message?.content as string || ''
// Clean up potential markdown blocks
const responseText = text.replace(/```/g, '').trim()
console.log('AI request:\n', raw)
console.log('AI response:\n', responseText)
console.log(`Finish reason: ${response.choices[0].finish_reason}`)
console.log(`Finish reason: ${response.choices[0].finishReason}`)
async function fail() {
if (tries > 0) {
@@ -236,11 +244,16 @@ export async function aiParseLyricsRaw(raw: string, tries: number = 5): Promise<
export async function aiParseLyrics(raw: string): Promise<LyricLine[]> {
// Split into maximum n lines per request
const n = 30
const maxLines = 30
const lines = raw.split('\n').filter(line => line.trim() !== '')
if (lines.length === 0) return []
const numChunks = Math.ceil(lines.length / maxLines)
const linesPerChunk = Math.ceil(lines.length / numChunks)
const chunks: string[] = []
for (let i = 0; i < lines.length; i += n) {
chunks.push(lines.slice(i, i + n).join('\n'))
for (let i = 0; i < lines.length; i += linesPerChunk) {
chunks.push(lines.slice(i, i + linesPerChunk).join('\n'))
}
const results = await Promise.all(chunks.map(it => aiParseLyricsRaw(it, 5)))
return results.flat()
+2 -2
View File
@@ -5,6 +5,6 @@ export const load: PageServerLoad = async ({ params }) => {
const songId = +params.id
const song = await getSongRaw(songId)
const lrc = await getLyricsProcessed(songId)
const audioUrl = await getSongUrl(songId)
return { song, lrc, audioUrl }
// const audioUrl = await getSongUrl(songId)
return { song, lrc, audioUrl: undefined }
}
+4 -1
View File
@@ -75,7 +75,7 @@
if (!startTime && input) startTime = Date.now()
console.log(`input changed: ${input}`)
// Convert to hiragana
inp = toHiragana(inp, { IMEMode: true })
inp = toHiragana(inp.replaceAll(' ', ''), { IMEMode: true })
const imeUsed = input !== inp
function findLoc() {
@@ -167,6 +167,9 @@
}
</script>
<svelte:window onresize={() => caret && animateCaret(caret)} />
<AppBar title={data.song.name} sub={artistAndAlbum(data.song)}>
<MenuItem textIcon="あ" onclick={() => settings.isFuri = !settings.isFuri}>{settings.isFuri ? "隐藏" : "显示"}假名标注</MenuItem>
<MenuItem textIcon="カ" onclick={() => settings.allKata = !settings.allKata}>{settings.allKata ? "恢复平假名" : "全部转换为片假名"}</MenuItem>
+3 -2
View File
@@ -4,7 +4,8 @@ import type { LyricSegment } from "../../../shared/types";
export type ProcLrcSeg = { swi: number, kanji?: string, kana: string }
export type ProcLrcLine = { parts: ProcLrcSeg[], totalLen: number }
export function processLrcLine(line: LyricSegment[]): ProcLrcLine {
let result: any[] = line.map(part => (typeof part === "string" ? { kana: part } : { kanji: part[0], kana: part[1] }))
let result: any[] = line.map(part => (typeof part === "string" ? { kana: part } :
toHiragana(part[0]) === toHiragana(part[1]) ? { kana: part[0] } : { kanji: part[0], kana: part[1] }))
let swi = 0
for (let item of result) {
item['swi'] = swi
@@ -14,7 +15,7 @@ export function processLrcLine(line: LyricSegment[]): ProcLrcLine {
}
// Fuzzy matching rules
const fuzzyMatch = [['わ', 'は'], ['を', 'お'], ['ず', 'づ'],
const fuzzyMatch = [['わ', 'は'], ['を', 'お'], ['ず', 'づ'], ['が', 'は'],
['ぁ', 'あ'], ['ぃ', 'い'], ['ぅ', 'う'], ['ぇ', 'え'], ['ぉ', 'お'],
['ゃ', 'や'], ['ゅ', 'ゆ'], ['ょ', 'よ'], ['っ', 'つ']]
export function fuzzyEquals(kana1: string, kana2: string): string {