初次提交
This commit is contained in:
76
src/lyric/common.ts
Normal file
76
src/lyric/common.ts
Normal file
@ -0,0 +1,76 @@
|
||||
import { ILyric } from "./declare"
|
||||
|
||||
/**
|
||||
* 将给定的值处理为整数或undefined
|
||||
* @param v 要处理的值
|
||||
*/
|
||||
function toInt(v: string | number) {
|
||||
if (typeof v == 'string') v = parseInt(v)
|
||||
if (typeof v != 'number') return undefined
|
||||
if (isNaN(v)) return undefined
|
||||
return parseInt(v as any)
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析歌词标签
|
||||
* @param lyric 歌词对象
|
||||
* @param line 歌词行
|
||||
*/
|
||||
export function parseLyricTag(lyric: ILyric, line: string) {
|
||||
const match = line.match(/^\[([a-zA-Z][a-zA-Z0-9_]*):(.*)\]$/)
|
||||
if (!match) return false
|
||||
switch (match[1]) {
|
||||
case 'ti':
|
||||
lyric.ti = match[2].trim()
|
||||
break
|
||||
case 'ar':
|
||||
lyric.ar = match[2].trim()
|
||||
break
|
||||
case 'al':
|
||||
lyric.al = match[2].trim()
|
||||
break
|
||||
case 'by':
|
||||
lyric.by = match[2].trim()
|
||||
break
|
||||
case 'offset':
|
||||
lyric.offset = toInt(match[2].trim())
|
||||
break
|
||||
default:
|
||||
lyric.ext ??= {}
|
||||
lyric.ext[match[1]] = match[2].trim()
|
||||
}
|
||||
return !!match?.[1]
|
||||
}
|
||||
|
||||
/** 生成标签 */
|
||||
interface IGenLyricTagOption {
|
||||
/** 忽略空的 */
|
||||
skipEmpty?: boolean
|
||||
}
|
||||
|
||||
//生成歌词标签
|
||||
export function genLyricTag(lyric: ILyric, option?: IGenLyricTagOption) {
|
||||
const buffer: string[] = []
|
||||
|
||||
const { skipEmpty = true } = option ?? {}
|
||||
|
||||
//kuwo特别处理
|
||||
if (lyric.ext?.kuwo) buffer.push(`[kuwo:${lyric.ext.kuwo}]`)
|
||||
|
||||
//常规标签
|
||||
if (!skipEmpty || lyric.ti) buffer.push(`[ti:${lyric.ti ?? ''}]`)
|
||||
if (!skipEmpty || lyric.ar) buffer.push(`[ar:${lyric.ar ?? ''}]`)
|
||||
if (!skipEmpty || lyric.al) buffer.push(`[al:${lyric.al ?? ''}]`)
|
||||
if (!skipEmpty || lyric.by) buffer.push(`[by:${lyric.by ?? ''}]`)
|
||||
if (lyric.offset) buffer.push(`[offset:${lyric.offset ?? ''}]`)
|
||||
|
||||
//其他标签
|
||||
if (lyric.ext) {
|
||||
for (let key in lyric.ext) {
|
||||
if (key === 'kuwo') continue
|
||||
if (!skipEmpty || lyric.ext[key]) buffer.push(`[${key}:${lyric.ext[key] ?? ''}]`)
|
||||
}
|
||||
}
|
||||
|
||||
return buffer
|
||||
}
|
61
src/lyric/declare.ts
Normal file
61
src/lyric/declare.ts
Normal file
@ -0,0 +1,61 @@
|
||||
/** 歌词类型 */
|
||||
export enum LyricType {
|
||||
/** 普通歌词 */
|
||||
REG,
|
||||
/** 卡拉OK歌词 */
|
||||
KARA,
|
||||
}
|
||||
|
||||
/** 卡拉OK歌词行信息 */
|
||||
export interface IKaraokeWord {
|
||||
/** 开始时间,相对于行时间 */
|
||||
start: number
|
||||
/** 持续时间 */
|
||||
duration: number
|
||||
/** 内容 */
|
||||
content: string
|
||||
}
|
||||
|
||||
/** 歌词行 */
|
||||
export interface ILyricLine<T> {
|
||||
/** 开始时间 */
|
||||
start: number
|
||||
/** 持续时间,普通歌词无此字段 */
|
||||
duration?: number
|
||||
/** 文字内容 */
|
||||
content: T
|
||||
}
|
||||
|
||||
interface ILyricCommon {
|
||||
/** 标题 */
|
||||
ti?: string
|
||||
/** 艺术家 */
|
||||
ar?: string
|
||||
/** 专辑 */
|
||||
al?: string
|
||||
/** 制作人 */
|
||||
by?: string
|
||||
/** 偏移 */
|
||||
offset?: number
|
||||
/** 其他扩展的标记 */
|
||||
ext?: { [K in string]: string }
|
||||
}
|
||||
|
||||
/** 普通歌词信息 */
|
||||
export interface IRegularLyric extends ILyricCommon {
|
||||
/** 歌词类型 */
|
||||
type: LyricType.REG
|
||||
/** 歌词内容 */
|
||||
content: ILyricLine<string>[]
|
||||
}
|
||||
|
||||
/** 卡拉歌词信息 */
|
||||
export interface IKaraOKLyric extends ILyricCommon {
|
||||
/** 歌词类型 */
|
||||
type: LyricType.KARA
|
||||
/** 歌词内容 */
|
||||
content: ILyricLine<IKaraokeWord[]>[]
|
||||
}
|
||||
|
||||
/** 歌词信息 */
|
||||
export type ILyric = (IRegularLyric | IKaraOKLyric)
|
115
src/lyric/krc.ts
Normal file
115
src/lyric/krc.ts
Normal file
@ -0,0 +1,115 @@
|
||||
|
||||
import { unescape } from 'yizhi-html-escape'
|
||||
import zlib from 'zlib'
|
||||
import { genLyricTag, parseLyricTag } from './common'
|
||||
import { IKaraokeWord, ILyric, ILyricLine, LyricType } from './declare'
|
||||
|
||||
const KUGOU_KEY = Buffer.from([64, 71, 97, 119, 94, 50, 116, 71, 81, 54, 49, 45, 206, 210, 110, 105])
|
||||
const KRC_TAG = Buffer.from([0x6b, 0x72, 0x63, 0x31])
|
||||
|
||||
//加密、解密
|
||||
function kugouConvert(buffer: Buffer) {
|
||||
for (let i = 0; i < buffer.byteLength; ++i) {
|
||||
buffer[i] ^= KUGOU_KEY[i % KUGOU_KEY.length]
|
||||
}
|
||||
return buffer
|
||||
}
|
||||
|
||||
/**
|
||||
* 酷狗歌词解密,并返回krc文本
|
||||
* @param buffer 歌词内容
|
||||
*/
|
||||
export function decrypt(buffer: Buffer) {
|
||||
//检测开头4个字节
|
||||
if (buffer.compare(KRC_TAG, 0, KRC_TAG.length, 0, KRC_TAG.length)) return null
|
||||
try {
|
||||
//解密
|
||||
buffer = kugouConvert(buffer.subarray(4))
|
||||
//解压
|
||||
return zlib.inflateSync(buffer).toString()
|
||||
} catch (err) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 加密krc歌词
|
||||
* @param krcText krc歌词文本
|
||||
*/
|
||||
export function encrypt(krcText: string) {
|
||||
//压缩
|
||||
let buffer = zlib.deflateSync(Buffer.from(krcText))
|
||||
//加密
|
||||
buffer = kugouConvert(buffer)
|
||||
//加上标记
|
||||
return Buffer.concat([KRC_TAG, buffer])
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析KRC歌词
|
||||
* @param krcText KRC歌词文本内容
|
||||
*/
|
||||
export function parse(krcText: string) {
|
||||
//结果
|
||||
const result: ILyric = { type: LyricType.KARA, content: [] }
|
||||
|
||||
//逐行处理
|
||||
krcText.split(/\r?\n/).forEach(line => {
|
||||
line = line.trim()
|
||||
//歌词行
|
||||
const match = line.match(/^\[\s*(\d+)\s*,\s*(\d+)\s*\](.*)$/)
|
||||
if (match) {
|
||||
//目标信息
|
||||
const lrcLine: ILyricLine<IKaraokeWord[]> = {
|
||||
start: parseInt(match[1]),
|
||||
duration: parseInt(match[2]),
|
||||
content: [],
|
||||
}
|
||||
//一直往后所搜词语
|
||||
let rem = match[3]
|
||||
while (rem.length) {
|
||||
const match = rem.match(/^<(\d+),(\d+),(\d+)>([^<]+)/)
|
||||
if (match) {
|
||||
const word: IKaraokeWord = {
|
||||
start: parseInt(match[1]),
|
||||
duration: parseInt(match[2]),
|
||||
content: unescape(match[4]),
|
||||
}
|
||||
if (word.content) lrcLine.content.push(word)
|
||||
rem = rem.substring(match[0].length)
|
||||
}
|
||||
else break
|
||||
}
|
||||
//保存行
|
||||
if (lrcLine.content.length) result.content.push(lrcLine)
|
||||
}
|
||||
|
||||
//标签
|
||||
else parseLyricTag(result, line)
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* 将歌词转换为KRC文本
|
||||
* @param lyric 歌词(只能是卡拉OK歌词)
|
||||
*/
|
||||
export function stringify(lyric: ILyric) {
|
||||
if (lyric.type != LyricType.KARA) throw new Error(`lrc cannot stringify to krc`)
|
||||
|
||||
const buffer: string[] = [...genLyricTag(lyric)]
|
||||
|
||||
lyric.content.forEach(line => {
|
||||
if (typeof line.content === 'string') return
|
||||
const dur = line.duration ?? (() => {
|
||||
const last = line.content[line.content.length - 1]
|
||||
if (!last) return 0
|
||||
return last.start + line.start + last.duration
|
||||
})()
|
||||
const text = line.content.map(word => `<${word.start},${word.duration},0>${word.content}`).join('')
|
||||
buffer.push(`[${line.start},${dur}]${text}`)
|
||||
})
|
||||
|
||||
return buffer.join('\n')
|
||||
}
|
74
src/lyric/lrc.ts
Normal file
74
src/lyric/lrc.ts
Normal file
@ -0,0 +1,74 @@
|
||||
import { parseLyricTag } from "./common"
|
||||
import { ILyric, LyricType } from "./declare"
|
||||
|
||||
/**
|
||||
* 解析LRC歌词
|
||||
* @param lyricText 歌词文本
|
||||
*/
|
||||
export function parse(lyricText: string) {
|
||||
//结果
|
||||
const result: ILyric = { type: LyricType.REG, content: [] }
|
||||
|
||||
//逐行处理
|
||||
lyricText.split(/\r?\n/).forEach(line => {
|
||||
line = line.trim()
|
||||
|
||||
//匹配歌词时间
|
||||
const startTimes: number[] = []
|
||||
while (true) {
|
||||
//匹配歌词时间
|
||||
const match = line.match(/^\[(\d+):(\d+)(\.(\d+))?\]/)
|
||||
if (match) {
|
||||
//取得时间
|
||||
const min = parseInt(match[1].replace(/^0+/, ''))
|
||||
const sec = parseInt(match[2].replace(/^0+/, ''))
|
||||
let sec100 = match[4] ? parseInt(match[4].replace(/^0+/, '')) : 0
|
||||
if (isNaN(min) || isNaN(sec)) return
|
||||
if (isNaN(sec100)) sec100 = 0
|
||||
const startTime = (min * 60 * 1000) + (sec * 1000) + (sec100 * 10);
|
||||
//保存时间继续处理
|
||||
if (!startTimes.includes(startTime)) startTimes.push(startTime)
|
||||
line = line.substring(match[0].length).trim()
|
||||
}
|
||||
//没有匹配到
|
||||
else break
|
||||
}
|
||||
|
||||
if (!line) return
|
||||
//如果匹配到时间
|
||||
if (startTimes.length) result.content.push(...startTimes.map(start => ({ start, content: line })))
|
||||
//没有匹配到歌词
|
||||
else parseLyricTag(result, line)
|
||||
})
|
||||
|
||||
//排序一下吧
|
||||
result.content = result.content.sort((a, b) => a.start - b.start)
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* 歌词转换为文本
|
||||
* @param lyric 歌词
|
||||
*/
|
||||
export function stringify(lyric: ILyric) {
|
||||
|
||||
const buffer: string[] = []
|
||||
|
||||
if (lyric.ti) buffer.push(`[ti:${lyric.ti}]`)
|
||||
if (lyric.ar) buffer.push(`[ar:${lyric.ar}]`)
|
||||
if (lyric.al) buffer.push(`[al:${lyric.al}]`)
|
||||
if (lyric.by) buffer.push(`[by:${lyric.by}]`)
|
||||
if (lyric.offset) buffer.push(`[offset:${lyric.offset}]`)
|
||||
|
||||
lyric.content.forEach(line => {
|
||||
const min = parseInt(line.start / 1000 / 60 as any).toString().padStart(2, '0')
|
||||
const sec = parseInt((line.start / 1000) % 60 as any).toString().padStart(2, '0')
|
||||
const sec100 = parseInt((line.start % 1000) / 10 as any).toString().padStart(2, '0')
|
||||
//文本处理
|
||||
const text = (typeof line.content == 'string') ? line.content : line.content.map(s => s.content).join('')
|
||||
buffer.push(`[${min}:${sec}.${sec100}]${text}`)
|
||||
})
|
||||
|
||||
return buffer.join('\n')
|
||||
}
|
161
src/lyric/lrcx.ts
Normal file
161
src/lyric/lrcx.ts
Normal file
@ -0,0 +1,161 @@
|
||||
import zlib from 'zlib'
|
||||
import iconv from 'iconv-lite'
|
||||
import { IKaraokeWord, ILyric, ILyricLine, LyricType } from './declare'
|
||||
import { genLyricTag, parseLyricTag } from './common'
|
||||
|
||||
|
||||
const KUWO_LYRIC_KEY = Buffer.from('yeelion')
|
||||
|
||||
//加密、解密
|
||||
function cryptLRCX(buffer: Buffer) {
|
||||
for (let i = 0; i < buffer.length; ++i) {
|
||||
buffer[i] ^= KUWO_LYRIC_KEY[i % KUWO_LYRIC_KEY.byteLength]
|
||||
}
|
||||
return buffer
|
||||
}
|
||||
|
||||
/**
|
||||
* 解密LRCX歌词
|
||||
* @param buffer 歌词内容
|
||||
* @returns 解密后的歌词,解密失败返回null
|
||||
*/
|
||||
export function decrypt(buffer: Buffer) {
|
||||
try {
|
||||
//解压
|
||||
const b64Str = zlib.inflateSync(buffer).toString()
|
||||
//取得内容
|
||||
const content = Buffer.from(b64Str, 'base64')
|
||||
//解密
|
||||
cryptLRCX(content)
|
||||
//完成
|
||||
return iconv.decode(content, 'gb18030')
|
||||
} catch (err) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 编码歌词
|
||||
* @param content 歌词的文本内容
|
||||
*/
|
||||
export function encrypt(content: string | Buffer) {
|
||||
//编码处理
|
||||
const codeData = iconv.encode(content.toString(), 'gb18030')
|
||||
//加密
|
||||
cryptLRCX(codeData)
|
||||
//转为base64
|
||||
const b64Str = codeData.toString('base64')
|
||||
//压缩
|
||||
return zlib.deflateSync(b64Str)
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析LRCX歌词
|
||||
* @param lrcxText LRCX歌词文本内容
|
||||
*/
|
||||
export function parse(lrcxText: string) {
|
||||
//结果
|
||||
const result: ILyric = { type: LyricType.KARA, content: [] }
|
||||
|
||||
//逐行处理
|
||||
lrcxText.split(/\r?\n/).forEach(line => {
|
||||
line = line.trim()
|
||||
const match = line.match(/^\[(\d+):(\d+).(\d+)\]/)
|
||||
|
||||
//需要读取到[kuwo:xxx]后才能解析
|
||||
if (match && result.ext?.kuwo) {
|
||||
const m = parseInt(match[1])
|
||||
const s = parseInt(match[2])
|
||||
const ms = parseInt(match[3])
|
||||
line = line.substring(match[0].length)
|
||||
|
||||
//得到kuwo标志中的8进制值
|
||||
const kuwo = parseInt(result.ext.kuwo, 8)
|
||||
const k1 = parseInt(kuwo / 10 as any)
|
||||
const k2 = parseInt(kuwo % 10 as any)
|
||||
|
||||
//解析歌词文字
|
||||
const lyricLine: ILyricLine<IKaraokeWord[]> = {
|
||||
start: (m * 60 * 1000) + (s * 1000) + ms,
|
||||
duration: 0,
|
||||
content: []
|
||||
}
|
||||
while (line.length) {
|
||||
const match = line.match(/<(\d+),([-\d]+)>([^<]+)/)
|
||||
if (!match) break
|
||||
const v1 = parseInt(match[1])
|
||||
const v2 = parseInt(match[2])
|
||||
line = line.substring(match[0].length)
|
||||
|
||||
const start = (v1 + v2) / (k1 * 2)
|
||||
const dur = (v1 - v2) / (k2 * 2)
|
||||
lyricLine.content.push({ start, duration: dur, content: match[3] })
|
||||
}
|
||||
|
||||
if (lyricLine.content.length) {
|
||||
//计算一下行持续时间
|
||||
const last = lyricLine.content[lyricLine.content.length - 1]
|
||||
lyricLine.duration = last.start + last.duration
|
||||
result.content.push(lyricLine)
|
||||
}
|
||||
}
|
||||
else parseLyricTag(result, line)
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* 将LRCX歌词转换为文本
|
||||
*
|
||||
* _注意:如果没有设置lyric.ext.kuwo,系统会自动生成一个_
|
||||
*
|
||||
* @param lyric 歌词(只能是卡拉OK歌词)
|
||||
*/
|
||||
export function stringify(lyric: ILyric) {
|
||||
if (lyric.type != LyricType.KARA) throw new Error(`lrc cannot stringify to lrcx`)
|
||||
|
||||
//处理一下歌词信息
|
||||
lyric = {
|
||||
...lyric,
|
||||
ext: {
|
||||
...lyric.ext ?? {},
|
||||
kuwo: lyric.ext?.kuwo ?? (() => {
|
||||
while (true) {
|
||||
const k = parseInt(Math.random() * (0o77 - 10) as any) + 10
|
||||
if (k % 10 != 0) return '0' + k.toString(8)
|
||||
}
|
||||
})(),
|
||||
}
|
||||
}
|
||||
|
||||
const buffer: string[] = [...genLyricTag(lyric, { skipEmpty: false })]
|
||||
|
||||
//取得kuwo标记
|
||||
const kuwo = parseInt(lyric.ext?.kuwo!, 8)
|
||||
const k1 = parseInt(kuwo / 10 as any)
|
||||
const k2 = parseInt(kuwo % 10 as any)
|
||||
|
||||
lyric.content.forEach(line => {
|
||||
if (typeof line.content === 'string') return
|
||||
const m = parseInt(line.start / 1000 / 60 as any).toString().padStart(2, '0')
|
||||
const s = parseInt(line.start / 1000 % 60 as any).toString().padStart(2, '0')
|
||||
const ms = parseInt(line.start % 1000 as any).toString().padStart(3, '0')
|
||||
|
||||
const text = line.content.map(word => {
|
||||
const v1 = word.start * (k1 * 2)
|
||||
const v2 = word.duration * (k2 * 2)
|
||||
|
||||
//求解方程:
|
||||
// a + b = v1
|
||||
// a - b = v2
|
||||
const a = parseInt((v1 + v2) / 2 as any)
|
||||
const b = parseInt((v1 - v2) / 2 as any)
|
||||
//完成
|
||||
return `<${a},${b}>${word.content}`
|
||||
}).join('')
|
||||
buffer.push(`[${m}:${s}.${ms}]${text}`)
|
||||
})
|
||||
|
||||
return buffer.join('\n')
|
||||
}
|
65
src/lyric/nrc.ts
Normal file
65
src/lyric/nrc.ts
Normal file
@ -0,0 +1,65 @@
|
||||
import { IKaraokeWord, ILyric, ILyricLine, LyricType } from './declare'
|
||||
import { genLyricTag, parseLyricTag } from './common'
|
||||
|
||||
/**
|
||||
* 解析nrc歌词
|
||||
* @param nrcxText nrc歌词文本内容
|
||||
*/
|
||||
export function parse(nrcxText: string) {
|
||||
//结果
|
||||
const result: ILyric = { type: LyricType.KARA, content: [] }
|
||||
|
||||
//逐行处理
|
||||
nrcxText.split(/\r?\n/).forEach(line => {
|
||||
line = line.trim()
|
||||
const match = line.match(/^\[(\d+),(\d+)\]/)
|
||||
if (match) {
|
||||
const lyricLine: ILyricLine<IKaraokeWord[]> = {
|
||||
start: parseInt(match[1]),
|
||||
duration: parseInt(match[2]),
|
||||
content: [],
|
||||
}
|
||||
line = line.substring(match[0].length)
|
||||
while (line.length) {
|
||||
const match = line.match(/^\((\d+),(\d+)(,(\d+))?\)([^\(]+)/)
|
||||
if (!match) break
|
||||
const start = parseInt(match[1])
|
||||
const dur = parseInt(match[2])
|
||||
const txt = match[5]
|
||||
lyricLine.content.push({
|
||||
start: start - lyricLine.start,
|
||||
duration: dur,
|
||||
content: txt,
|
||||
})
|
||||
line = line.substring(match[0].length)
|
||||
}
|
||||
if (lyricLine.content.length) result.content.push(lyricLine)
|
||||
}
|
||||
else parseLyricTag(result, line)
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* 将nrc歌词转换为文本
|
||||
*
|
||||
* @param lyric 歌词(只能是卡拉OK歌词)
|
||||
*/
|
||||
export function stringify(lyric: ILyric) {
|
||||
if (lyric.type != LyricType.KARA) throw new Error(`lrc cannot stringify to nrc`)
|
||||
|
||||
const buffer: string[] = [...genLyricTag(lyric)]
|
||||
|
||||
|
||||
lyric.content.forEach(line => {
|
||||
if (typeof line.content === 'string') return
|
||||
|
||||
const text = line.content.map(word => {
|
||||
return `(${word.start + line.start},${word.duration},${0})${word.content}`
|
||||
}).join('')
|
||||
buffer.push(`[${line.start},${line.duration}]${text}`)
|
||||
})
|
||||
|
||||
return buffer.join('\n')
|
||||
}
|
230
src/lyric/qrc.ts
Normal file
230
src/lyric/qrc.ts
Normal file
@ -0,0 +1,230 @@
|
||||
import zlib from 'zlib'
|
||||
import { des, DesType } from '../lib/des'
|
||||
import { parseXML, XMLError } from '../lib/xml';
|
||||
import { IKaraokeWord, ILyric, ILyricLine, LyricType } from './declare';
|
||||
import { escape, unescape } from 'yizhi-html-escape'
|
||||
import { genLyricTag, parseLyricTag } from './common';
|
||||
|
||||
//歌词密码
|
||||
const QRC_KEY1 = Buffer.from("!@#)(NHLiuy*$%^&");
|
||||
const QRC_KEY2 = Buffer.from("123ZXC!@#)(*$%^&");
|
||||
const QRC_KEY3 = Buffer.from("!@#)(*$%^&abcDEF");
|
||||
|
||||
/** 新旧版转换密码 */
|
||||
const VERSION_CONVERT_CODE = [
|
||||
0x77, 0x48, 0x32, 0x73, 0xDE, 0xF2, 0xC0, 0xC8, 0x95, 0xEC, 0x30, 0xB2, 0x51, 0xC3, 0xE1, 0xA0,
|
||||
0x9E, 0xE6, 0x9D, 0xCF, 0xFA, 0x7F, 0x14, 0xD1, 0xCE, 0xB8, 0xDC, 0xC3, 0x4A, 0x67, 0x93, 0xD6,
|
||||
0x28, 0xC2, 0x91, 0x70, 0xCA, 0x8D, 0xA2, 0xA4, 0xF0, 0x08, 0x61, 0x90, 0x7E, 0x6F, 0xA2, 0xE0,
|
||||
0xEB, 0xAE, 0x3E, 0xB6, 0x67, 0xC7, 0x92, 0xF4, 0x91, 0xB5, 0xF6, 0x6C, 0x5E, 0x84, 0x40, 0xF7,
|
||||
0xF3, 0x1B, 0x02, 0x7F, 0xD5, 0xAB, 0x41, 0x89, 0x28, 0xF4, 0x25, 0xCC, 0x52, 0x11, 0xAD, 0x43,
|
||||
0x68, 0xA6, 0x41, 0x8B, 0x84, 0xB5, 0xFF, 0x2C, 0x92, 0x4A, 0x26, 0xD8, 0x47, 0x6A, 0x7C, 0x95,
|
||||
0x61, 0xCC, 0xE6, 0xCB, 0xBB, 0x3F, 0x47, 0x58, 0x89, 0x75, 0xC3, 0x75, 0xA1, 0xD9, 0xAF, 0xCC,
|
||||
0x08, 0x73, 0x17, 0xDC, 0xAA, 0x9A, 0xA2, 0x16, 0x41, 0xD8, 0xA2, 0x06, 0xC6, 0x8B, 0xFC, 0x66,
|
||||
0x34, 0x9F, 0xCF, 0x18, 0x23, 0xA0, 0x0A, 0x74, 0xE7, 0x2B, 0x27, 0x70, 0x92, 0xE9, 0xAF, 0x37,
|
||||
0xE6, 0x8C, 0xA7, 0xBC, 0x62, 0x65, 0x9C, 0xC2, 0x08, 0xC9, 0x88, 0xB3, 0xF3, 0x43, 0xAC, 0x74,
|
||||
0x2C, 0x0F, 0xD4, 0xAF, 0xA1, 0xC3, 0x01, 0x64, 0x95, 0x4E, 0x48, 0x9F, 0xF4, 0x35, 0x78, 0x95,
|
||||
0x7A, 0x39, 0xD6, 0x6A, 0xA0, 0x6D, 0x40, 0xE8, 0x4F, 0xA8, 0xEF, 0x11, 0x1D, 0xF3, 0x1B, 0x3F,
|
||||
0x3F, 0x07, 0xDD, 0x6F, 0x5B, 0x19, 0x30, 0x19, 0xFB, 0xEF, 0x0E, 0x37, 0xF0, 0x0E, 0xCD, 0x16,
|
||||
0x49, 0xFE, 0x53, 0x47, 0x13, 0x1A, 0xBD, 0xA4, 0xF1, 0x40, 0x19, 0x60, 0x0E, 0xED, 0x68, 0x09,
|
||||
0x06, 0x5F, 0x4D, 0xCF, 0x3D, 0x1A, 0xFE, 0x20, 0x77, 0xE4, 0xD9, 0xDA, 0xF9, 0xA4, 0x2B, 0x76,
|
||||
0x1C, 0x71, 0xDB, 0x00, 0xBC, 0xFD, 0x0C, 0x6C, 0xA5, 0x47, 0xF7, 0xF6, 0x00, 0x79, 0x4A, 0x11,
|
||||
]
|
||||
|
||||
/*** 歌词标识 */
|
||||
const OFFSET_TAG = Buffer.from('[offset:')
|
||||
|
||||
//新旧版本转换
|
||||
function convertNewQRC(content: Buffer) {
|
||||
const result = Buffer.alloc(content.byteLength)
|
||||
for (let i = 0; i < content.byteLength; ++i) {
|
||||
result[i] = content[i] ^ VERSION_CONVERT_CODE[(i * i + 0x013c1b) % 256]
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
//填充Buffer为8的倍数
|
||||
function padBuffer(buffer: Buffer, offset: number) {
|
||||
let len = buffer.byteLength - offset
|
||||
const mod = len % 8
|
||||
if (mod) len += 8 - mod
|
||||
const result = Buffer.alloc(len, 0)
|
||||
buffer.copy(result, 0, offset, buffer.length)
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* 解密QRC歌词,得到歌词XML文本
|
||||
* @param buffer 歌词内容,可以从.qrc文件读取,也可以通过QQ音乐API下载
|
||||
* @returns 解密后的歌词,解密失败返回null
|
||||
*/
|
||||
export function decrypt(buffer: Buffer) {
|
||||
try {
|
||||
//如果是新版的,转换为旧版
|
||||
if (buffer[0] == 0x98 && buffer[1] == 0x25) buffer = convertNewQRC(buffer)
|
||||
|
||||
//检测是否有"[offset:", 如果有则为歌词
|
||||
if (!buffer.compare(OFFSET_TAG, 0, OFFSET_TAG.length, 0, OFFSET_TAG.length)) {
|
||||
//填补剩余空间
|
||||
const content = padBuffer(buffer, 0x0b)
|
||||
|
||||
//解密
|
||||
des(content, QRC_KEY1, DesType.Decode)
|
||||
des(content, QRC_KEY2, DesType.Encode)
|
||||
des(content, QRC_KEY3, DesType.Decode)
|
||||
|
||||
//解压,得到歌词
|
||||
const result = zlib.inflateSync(content)
|
||||
return result.toString()
|
||||
}
|
||||
|
||||
//尝试解码网络获取的歌词
|
||||
const result = padBuffer(buffer, 0)
|
||||
des(result, QRC_KEY1, DesType.Decode)
|
||||
des(result, QRC_KEY2, DesType.Encode)
|
||||
des(result, QRC_KEY3, DesType.Decode)
|
||||
return zlib.unzipSync(result).toString()
|
||||
} catch (err) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** QRC歌词编码类型 */
|
||||
export enum EncryptType {
|
||||
/** 新版歌词 */
|
||||
QRC,
|
||||
/** 旧版歌词 */
|
||||
OLD_QRC,
|
||||
}
|
||||
|
||||
/**
|
||||
* 编码歌词
|
||||
* @param content 歌词的XML内容
|
||||
* @param type 歌词类型,默认为新版的歌词
|
||||
*/
|
||||
export function encrypt(content: string | Buffer, type = EncryptType.QRC) {
|
||||
//压缩
|
||||
let result = zlib.deflateSync(content)
|
||||
|
||||
//加密
|
||||
result = padBuffer(result, 0)
|
||||
des(result, QRC_KEY3, DesType.Encode)
|
||||
des(result, QRC_KEY2, DesType.Decode)
|
||||
des(result, QRC_KEY1, DesType.Encode)
|
||||
|
||||
//加上标签
|
||||
result = Buffer.concat([
|
||||
Buffer.from('[offset:0]\n'),
|
||||
result,
|
||||
])
|
||||
|
||||
//转换为新版的歌词
|
||||
if (type === EncryptType.QRC) result = convertNewQRC(result)
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析QRC歌词
|
||||
* @param qrcText QRC歌词文本内容(解码后的XML格式)
|
||||
*/
|
||||
export function parse(qrcText: string) {
|
||||
//解析XML数据,得到歌词数据
|
||||
const lyric = (() => {
|
||||
try {
|
||||
const res = parseXML(qrcText).root
|
||||
//QrcInfos标签
|
||||
if (res.name.toLowerCase() != 'QrcInfos'.toLowerCase()) throw new Error('match QrcInfos TAG failed')
|
||||
if (!(res.children instanceof Array)) throw new Error('match QrcInfos TAG failed')
|
||||
|
||||
//LyricInfo标签
|
||||
const lyricInfoTag = res.children.find(e => e.name.toLowerCase() === 'LyricInfo'.toLowerCase())
|
||||
if (!lyricInfoTag) throw new Error(`match LyricInfo TAG failed`)
|
||||
if (!(lyricInfoTag.children instanceof Array)) throw new Error(`match LyricInfo TAG failed`)
|
||||
|
||||
//歌词列表
|
||||
const lyricTags = lyricInfoTag.children.filter(t => /^Lyric_\d+/.test(t.name))
|
||||
return lyricTags.map(t => {
|
||||
const lyric = { type: 1, content: '' }
|
||||
t.props.forEach(p => {
|
||||
if (p.name.toLowerCase() == 'LyricContent'.toLowerCase()) lyric.content = p.value ?? ''
|
||||
else if (p.name.toLowerCase() == 'LyricType') lyric.type = parseInt(p.value ?? '1')
|
||||
})
|
||||
return lyric
|
||||
}).filter(l => l.content)
|
||||
} catch (err) {
|
||||
if (err instanceof XMLError) throw new Error(`parse QRC XML data failed`)
|
||||
else throw err
|
||||
}
|
||||
})()[0]
|
||||
if (!lyric) throw new Error(`QRC file not contains any lyric data`)
|
||||
|
||||
//结果
|
||||
const result: ILyric = { type: LyricType.KARA, content: [] }
|
||||
|
||||
//逐行处理
|
||||
lyric.content.split(/\r?\n/).forEach(line => {
|
||||
line = line.trim()
|
||||
//歌词行
|
||||
const match = line.match(/^\[\s*(\d+)\s*,\s*(\d+)\s*\](.*)$/)
|
||||
if (match) {
|
||||
//目标信息
|
||||
const lrcLine: ILyricLine<IKaraokeWord[]> = {
|
||||
start: parseInt(match[1]),
|
||||
duration: parseInt(match[2]),
|
||||
content: [],
|
||||
}
|
||||
//一直往后所搜词语
|
||||
let rem = match[3]
|
||||
while (rem.length) {
|
||||
const match = rem.match(/\(\s*(\d+)\s*,\s*(\d+)\s*\)/)
|
||||
if (match) {
|
||||
const word: IKaraokeWord = {
|
||||
start: parseInt(match[1]) - lrcLine.start,
|
||||
duration: parseInt(match[2]),
|
||||
content: unescape(rem.substring(0, match.index!)),
|
||||
}
|
||||
if (word.content) lrcLine.content.push(word)
|
||||
rem = rem.substring(match.index! + match[0].length)
|
||||
}
|
||||
}
|
||||
//保存行
|
||||
if (lrcLine.content.length) result.content.push(lrcLine)
|
||||
}
|
||||
|
||||
//标签
|
||||
else parseLyricTag(result, line)
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* 将歌词转换为XML文本
|
||||
* @param lyric 歌词(只能是卡拉OK歌词)
|
||||
*/
|
||||
export function stringify(lyric: ILyric) {
|
||||
if (lyric.type != LyricType.KARA) throw new Error(`lrc cannot stringify to qrc`)
|
||||
|
||||
const buffer: string[] = [...genLyricTag(lyric)]
|
||||
|
||||
lyric.content.forEach(line => {
|
||||
if (typeof line.content === 'string') return
|
||||
const dur = line.duration ?? (() => {
|
||||
const last = line.content[line.content.length - 1]
|
||||
if (!last) return 0
|
||||
return last.start + line.start + last.duration
|
||||
})()
|
||||
const text = line.content.map(word => `${word.content}(${word.start + line.start},${word.duration})`).join('')
|
||||
buffer.push(`[${line.start},${dur}]${text}`)
|
||||
})
|
||||
|
||||
return [
|
||||
`<?xml version="1.0" encoding="utf-8"?>`,
|
||||
`<QrcInfos>`,
|
||||
` <QrcHeadInfo SaveTime="${parseInt(Date.now() / 1000 as any)}" Version="100" />`,
|
||||
` <LyricInfo LyricCount="1">`,
|
||||
` <Lyric_1 LyricType="1" LyricContent="${escape(buffer.join('\n'))}\n" />`,
|
||||
` </LyricInfo>`,
|
||||
`</QrcInfos>`,
|
||||
].join('\n')
|
||||
}
|
Reference in New Issue
Block a user