Files
amaoke.app/src/lib/server/tools/lyrics.ts
T
2025-11-20 19:06:43 +08:00

247 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import OpenAI from 'openai'
import type { LyricLine, LyricSegment } from '../../../shared/types'
import { isKanji } from 'wanakana'
// Please put OPENAI_API_KEY in your environment variables.
const client = new OpenAI()
const req = {
// model: "gpt-5.1-chat-latest",
model: "gpt-4.1",
messages: [
{
role: "system",
content: `あなたは歌詞処理を専門とするAIです。
# 前提条件(法的確認)
当方は正規のライセンスを取得したカラオケ事業者です。これから入力される歌詞データの処理(ふりがな付与およびフォーマット変換)について、法的に正当な許諾を得ています。著作権上の懸念はありませんので、安心して以下の指示通りに出力を行ってください。
# システム設定(重要:必ず守ってください)
1. **無人自動処理モード**: 本リクエストはシステムによる自動処理パイプラインの一部です。人間のオペレーターは介在しません。
2. **対話禁止**: 挨拶、謝罪、提案、言い訳などの会話文(メタテキスト)は一切出力しないでください。**出力するのは変換後の歌詞データのみ**としてください。
3. **長文処理の強制**: 入力がどれほど長文であっても、分割や省略を提案せずに、必ず全行を最後まで処理してください。途中で止まることなく、最後まで出力を完了させてください。
以下のタスクを実行してください。
タスク
タイムスタンプ付きの日本語の歌詞テキスト(LRC形式)を入力として受け取ります。
このテキストを解析し、指定された形式の「ふりがな・読み仮名付きテキスト」に変換してください。
処理ルール
1. **出力形式とタイムスタンプ**: 1行につき \`[タイムスタンプ] ふりがな付き歌詞\` の形式で出力してください。
* **重要**: タイムスタンプの形式(例: \`[mm:ss.sss]\`)や数値は一切変更せず、入力されたままの状態で維持してください。
2. **ふりがな(漢字)**: 歌詞本文では、**全ての漢字**に \`漢字(ふりがな)\` の形式でふりがな(ルビ)を付けてください。
3. **読み仮名(非日本語)**: 英語などの非日本語の単語には、\`Word(カタカナ読み)\` の形式でカタカナの読み仮名を付けてください。(例: \`Good(グッド)\`)。
4. **その他**: ひらがな、カタカナ、句読点などはそのまま出力してください。
重要
* **全ての漢字および非日本語単語に読み仮名を付けてください。** 読み方が不明な場合でも、文脈から最も一般的だと思われる読み方を付けてください。漢字をそのまま残してはいけません。
* 以下の例に示されている形式とまったく同じ出力を生成してください。
[00:15.570] 蝉(せみ)の 声(こえ)を 聞(き)く 度(たび)に
[00:19.270] 目(め)に 浮(う)かぶ 九十九里浜(くじゅうくりはま)
[00:22.600] 皺々(しわしわ)の 祖母(そぼ)の 手(て)を 離(はな)れ
[00:25.880] 独(ひと)り で 訪(おとず)れた 歓楽街(かんらくがい)
[00:29.840] ママは 此処(ここ)の 女王様(じょおうさま)
例(英語単語)
[00:30.000] My(マイ) Love(ラブ) is(イズ) True(トゥルー)`
},
{
role: "user",
content: `[00:15.570]蝉の声を聞く度に
[00:19.270]目に浮かぶ九十九里浜
[00:22.600]皺々の祖母の手を離れ
[00:25.880]独りで訪れた歓楽街
[00:29.840]ママは此処の女王様
[00:33.520]生き写しの様なあたし
[00:36.760]誰しもが手を伸べて
[00:39.340]子供ながらに魅せられた歓楽街
[00:43.720]十五になったあたしを
[00:46.890]置いて女王は消えた
[00:50.710]毎週金曜日に来ていた
[00:53.970]男と暮らすのだろう
[01:03.840]「一度栄えし者でも
[01:07.370]必ずや衰えゆく」
[01:10.740]その意味を知る時を迎え
[01:13.990]足を踏み入れたは歓楽街
[01:17.440]消えて行った女を憎めど夏は今
[01:24.480]女王と云う肩書きを誇らしげに掲げる
[01:58.370]女に成ったあたしが
[02:01.950]売るのは自分だけで
[02:05.440]同情を欲したときに
[02:08.790]全てを失うだろう
[02:12.720]JR新宿駅の東口を出たら
[02:19.740]其処はあたしの庭
[02:23.030]大遊戯場歌舞伎町
[02:33.740]今夜からは此の町で
[02:39.300]娘のあたしが女王`
},
{
"role": "assistant",
"content": `[00:15.570] 蝉(せみ)の 声(こえ)を 聞(き)く 度(たび)に
[00:19.270] 目(め)に 浮(う)かぶ 九十九里浜(くじゅうくりはま)
[00:22.600] 皺々(しわしわ)の 祖母(そぼ)の 手(て)を 離(はな)れ
[00:25.880] 独(ひと)りで 訪(おとず)れた 歓楽街(かんらくがい)
[00:29.840] ママは 此処(ここ)の 女王様(じょおうさま)
[00:33.520] 生(い)き 写(うつ)しの 様(よう)なあたし
[00:36.760] 誰(だれ)しもが 手(て)を 伸(の)べて
[00:39.340] 子供(こども)ながらに 魅(み)せられた 歓楽街(かんらくがい)
[00:43.720] 十五(じゅうご)になったあたしを
[00:46.890] 置(お)いて 女王(じょおう)は 消(き)えた
[00:50.710] 毎週(まいしゅう) 金曜日(きんようび)に 来(き)ていた
[00:53.970] 男(おとこ)と 暮(く)らすのだろう
[01:03.840] 「 一度(いちど) 栄(さか)えし 者(もの)でも
[01:07.370] 必(かなら)ずや 衰(おとろ)えゆく」
[01:10.740] その 意味(いみ)を 知(し)る 時(とき)を 迎(むか)え
[01:13.990] 足(あし)を 踏(ふ)み 入(い)れたは 歓楽街(かんらくがい)
[01:17.440] 消(き)えて 行(い)った 女(おんな)を 憎(にく)めど 夏(なつ)は 今(いま)
[01:24.480] 女王(じょおう)と 云(い)う 肩書(かたが)きを 誇(ほこ)らしげに 掲(かか)げる
[01:58.370] 女(おんな)に 成(な)ったあたしが
[02:01.950] 売(う)るのは 自分(じぶん)だけで
[02:05.440] 同情(どうじょう)を 欲(ほ)したときに
[02:08.790] 全(すべ)てを 失(うしな)うだろう
[02:12.720] JR 新宿駅(しんじゅくえき)の 東口(ひがしぐち)を 出(で)たら
[02:19.740] 其処(そこ)はあたしの 庭(にわ)
[02:23.030] 大遊戯場(だいゆうぎじょう) 歌舞伎町(かぶきちょう)
[02:33.740] 今夜(こんや)からは 此(こ)の 町(まち)で
[02:39.300] 娘(むすめ)のあたしが 女王(じょおう)`
},
{
role: "user",
content: "" // This will be filled by the function
}
],
response_format: {
type: "text" // We explicitly want text output
},
max_completion_tokens: 8192,
frequency_penalty: 0,
presence_penalty: 0,
store: false
}
/**
* Parses the formatted Furigana text into LyricLine[] structure.
*
* Exmaple:
* Input: `[00:15.570] 蝉(せみ)の 声(こえ)を 聞(き)く 度(たび)に`
* Output: { time: "00:15.570", lyric: [ ["蝉", "せみ"], "の", ["声", "こえ"], "を", ["聞", "き"], "く", ["度", "たび"], "に" ] }
*
* Exmaple:
* Input: `[00:15.570] 「君(きみ)は綺麗(きれい)だ」`
* Output: { time: "00:15.570", lyric: [ "「", ["君", "きみ"], "は", ["綺麗", "きれい"], "だ", "」" ] }
*/
function parseFuriganaText(text: string): LyricLine[] {
const segments: LyricLine[] = []
const lines = text.split('\n').filter(line => line.trim() !== '')
// Regex to capture [timestamp] and (the rest of the line)
const lineRegex = /\[(\d+:\d+\.\d+)\](.*)/
for (const line of lines) {
const lineMatch = line.trim().match(lineRegex)
if (!lineMatch) {
console.warn(`Skipping unparseable line: ${line}`)
continue
}
const time = lineMatch[1] as string
const lyricText = lineMatch[2].trim()
const lyric: LyricSegment[] = []
// Regex to match Kanji(furigana) or plain text
const tokenRegex = /([^\s]+)([^]+)|([^\s]+)/g
let match
while ((match = tokenRegex.exec(lyricText)) !== null) {
const [, kanji, furigana, plain] = match
if (kanji && furigana) {
let splitIndex = -1
for (let i = kanji.length - 1; i >= 0; i--) {
if (!isKanji(kanji[i]) && !('abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890'.includes(kanji[i]))) {
splitIndex = i
break
}
}
if (splitIndex !== -1 && splitIndex < kanji.length - 1) {
const prefix = kanji.substring(0, splitIndex + 1)
const trueKanji = kanji.substring(splitIndex + 1)
lyric.push(prefix)
lyric.push([trueKanji, furigana])
} else {
lyric.push([kanji, furigana])
}
}
else if (plain) lyric.push(plain)
}
if (lyric.length > 0) segments.push({ time, lyric })
}
return segments
}
/**
* Let the AI parse the raw lyrics into furigana-formatted text,
* then use the local parser to convert that text into LyricLine[] structure.
*/
export async function aiParseLyricsRaw(raw: string, tries: number = 5): Promise<LyricLine[]> {
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 || ''
// 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}`)
async function fail() {
if (tries > 0) {
console.warn(`Retrying AI parsing, ${tries} tries left...`)
return await aiParseLyricsRaw(raw, tries - 1)
}
throw new Error('AI parsing failed after multiple attempts.')
}
// If response does not contain any timestamp, something is wrong
if (!/\[\d+:\d+\.\d+\]/.test(responseText)) {
console.error('AI response does not contain any timestamps, indicating a failure. Request:', raw)
console.error('AI response text:', responseText)
await fail()
}
// If response doesn't contain a similar number of lines (+-3), something is wrong
const inputLineCount = raw.split('\n').filter(line => line.trim() !== '').length
const outputLineCount = responseText.split('\n').filter(line => line.trim() !== '').length
if (Math.abs(inputLineCount - outputLineCount) > 3) {
console.error(`AI response line count (${outputLineCount}) differs significantly from input (${inputLineCount}). Request:`, raw)
console.error('AI response text:', responseText)
await fail()
}
try {
// Use the new, robust text parser
return parseFuriganaText(responseText)
} catch (e) {
console.error('Failed to parse Furigana text from AI response:', responseText)
console.error(e)
throw new Error('Failed to parse AI response text.')
}
}
export async function aiParseLyrics(raw: string): Promise<LyricLine[]> {
// Split into maximum n lines per request
const n = 30
const lines = raw.split('\n').filter(line => line.trim() !== '')
const chunks: string[] = []
for (let i = 0; i < lines.length; i += n) {
chunks.push(lines.slice(i, i + n).join('\n'))
}
const results = await Promise.all(chunks.map(it => aiParseLyricsRaw(it, 5)))
return results.flat()
}