diff --git a/bun.lock b/bun.lock index 87f1ea8..ef13174 100644 --- a/bun.lock +++ b/bun.lock @@ -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=="], diff --git a/package.json b/package.json index 6754f78..a194149 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/lib/server/tools/lyrics.ts b/src/lib/server/tools/lyrics.ts index 93fc615..868d964 100644 --- a/src/lib/server/tools/lyrics.ts +++ b/src/lib/server/tools/lyrics.ts @@ -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 { // 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() diff --git a/src/routes/song/[id]/+page.server.ts b/src/routes/song/[id]/+page.server.ts index 7644b49..d4038ca 100644 --- a/src/routes/song/[id]/+page.server.ts +++ b/src/routes/song/[id]/+page.server.ts @@ -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 } } \ No newline at end of file diff --git a/src/routes/song/[id]/+page.svelte b/src/routes/song/[id]/+page.svelte index 43da04a..bd3d3bd 100644 --- a/src/routes/song/[id]/+page.svelte +++ b/src/routes/song/[id]/+page.svelte @@ -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 @@ } + caret && animateCaret(caret)} /> + + settings.isFuri = !settings.isFuri}>{settings.isFuri ? "隐藏" : "显示"}假名标注 settings.allKata = !settings.allKata}>{settings.allKata ? "恢复平假名" : "全部转换为片假名"} diff --git a/src/routes/song/[id]/IMEHelper.ts b/src/routes/song/[id]/IMEHelper.ts index c0d9fbf..b114f5e 100644 --- a/src/routes/song/[id]/IMEHelper.ts +++ b/src/routes/song/[id]/IMEHelper.ts @@ -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 {