LyricUtil.ets 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307
  1. import fs from '@ohos.file.fs';
  2. import common from '@ohos.app.ability.common';
  3. import fileIo from '@ohos.file.fs';
  4. import { LyricWord } from '@seagazer/cclyric/src/main/ets/bean/LyricWord';
  5. // 定义接口来描述返回值的类型
  6. interface ParseResult {
  7. timeline: number;
  8. words: LyricWord[];
  9. }
  10. // 定义Navidrome歌词行的接口
  11. interface NavidromeLyricLine {
  12. start: number;
  13. value: string;
  14. }
  15. // 定义歌词行接口(用于蓝牙歌词)
  16. interface LyricLineItem {
  17. time: number;
  18. text: string;
  19. }
  20. // 定义Navidrome语言数据的接口
  21. interface NavidromeLangData {
  22. lang?: string;
  23. line: NavidromeLyricLine[];
  24. }
  25. class LyricUtil {
  26. // 检测方括号格式的逐字歌词 [00:00.000]文[00:01.000]字
  27. private isSquareBracketWordByWordLyric(line: string): boolean {
  28. return /\[\d{2}:\d{2}\.\d{2,3}\]\S/.test(line);
  29. }
  30. // 解析方括号格式的逐字歌词行
  31. private parseSquareBracketWordLine(line: string, offset: number): ParseResult {
  32. const words: LyricWord[] = [];
  33. let firstTimeline = -1;
  34. // 正则匹配:[00:00.000]中文字
  35. const regex = /\[(\d{2}:\d{2}\.\d{2,3})\]([^\[]*)/g;
  36. // 显式指定 match 的类型
  37. let match: RegExpExecArray | null;
  38. while ((match = regex.exec(line))!== null) {
  39. const timeStr = match[1]; // 时间部分 00:00.000
  40. const word = match[2].trim(); // 歌词文本
  41. if (!word) continue; // 跳过空词
  42. const timeline = this.parseTimeline2(timeStr) - offset;
  43. if (firstTimeline < 0) firstTimeline = timeline;
  44. words.push(new LyricWord(word, timeline, 0));
  45. }
  46. // 计算每个词的持续时间
  47. for (let i = 0; i < words.length - 1; i++) {
  48. words[i].duration = words[i + 1].startTime - words[i].startTime;
  49. }
  50. if (words.length > 0 && words[words.length - 1].duration === 0) {
  51. words[words.length - 1].duration = 200; // 默认200ms
  52. }
  53. return { timeline: firstTimeline, words };
  54. }
  55. /******************** 时间解析增强 ********************/
  56. private parseTimeline2(timeString: string): number {
  57. // 增强支持毫秒/厘秒解析
  58. const parts = timeString.split(':');
  59. const minutes = parseInt(parts[0], 10);
  60. const secondParts = parts[1].split('.');
  61. const seconds = parseInt(secondParts[0], 10);
  62. const fraction = parseInt(secondParts[1], 10);
  63. // 根据小数位长度判断时间精度
  64. const milliseconds = secondParts[1].length === 2 ?
  65. fraction * 10 : // 厘秒转毫秒 (01 -> 10ms)
  66. fraction; // 毫秒直接使用
  67. return minutes * 60000 + seconds * 1000 + milliseconds;
  68. }
  69. // 判断是否是逐字歌词行
  70. private isWordByWordLyric(line: string): boolean {
  71. const hasBracketTimestamp = /\(\d{2}:\d{2}\.\d{2,3}\)\S/.test(line);
  72. const hasAngleTimestamp = /<\d{2}:\d{2}\.\d{2,3}>/.test(line);
  73. return hasBracketTimestamp || hasAngleTimestamp;
  74. }
  75. /**
  76. * 将整个逐字歌词内容转换为最简单的LRC格式
  77. * @param lyricContent 整个歌词内容(字符串)
  78. * @returns 转换后的LRC格式字符串
  79. */
  80. public convertLyricToSimpleLrc(lyricContent: string): string {
  81. let lyrics = lyricContent.split('\n').map(line => line.trim());
  82. const result: string[] = [];
  83. const ignoredTags = ['ti', 'ar', 'al', 'by', 'offset', 'hash', 'sign', 'qq', 'total', 'Outro', 'tool'];
  84. for (let i = 0; i < lyrics.length; i++) {
  85. const line = lyrics[i].trim();
  86. // 跳过空行和特定标签行
  87. if (!line) continue;
  88. if (ignoredTags.some(tag => line.startsWith(`[${tag}]`))) continue;
  89. // 处理逐字歌词行
  90. if (this.isSquareBracketWordByWordLyric(line) || this.isWordByWordLyric(line)) {
  91. let firstTimestamp = "";
  92. let fullText = "";
  93. // 处理尖括号格式:<00:00.000>文<00:01.000>字
  94. if (this.isWordByWordLyric(line)) {
  95. const regex = /<(\d{2}:\d{2}\.\d{2,3})>([^<]*)/g;
  96. let match: RegExpExecArray | null;
  97. while ((match = regex.exec(line)) !== null) {
  98. const timestamp = match[1];
  99. const word = match[2].trim();
  100. if (!firstTimestamp) firstTimestamp = timestamp;
  101. fullText += word;
  102. }
  103. }else
  104. // 处理方括号格式:[00:00.000]文[00:01.000]字
  105. if (this.isSquareBracketWordByWordLyric(line)) {
  106. const regex = /\[(\d{2}:\d{2}\.\d{2,3})\]([^\[]*)/g;
  107. let match: RegExpExecArray | null;
  108. while ((match = regex.exec(line)) !== null) {
  109. const timestamp = match[1];
  110. const word = match[2].trim();
  111. if (!firstTimestamp) firstTimestamp = timestamp;
  112. fullText += word;
  113. }
  114. }
  115. // 添加到结果中
  116. if (firstTimestamp && fullText) {
  117. result.push(`[${firstTimestamp}]${fullText}`);
  118. }
  119. }
  120. // 保留普通LRC行
  121. else {
  122. result.push(line);
  123. }
  124. }
  125. // 将结果数组连接成单一字符串
  126. return result.join('\n');
  127. }
  128. /**
  129. * 将Navidrome的JSON格式歌词转换为LRC标准格式
  130. * JSON格式: [{"lang":"xxx","line":[{"start":0,"value":"歌词内容"},...]}]
  131. * LRC格式: [00:00.00]歌词内容
  132. * @param jsonLyric Navidrome返回的JSON格式歌词
  133. * @returns 转换后的LRC格式歌词,如果转换失败返回undefined
  134. */
  135. public convertNavidromeJsonLyricToLrc(jsonLyric: string): string | undefined {
  136. if (!jsonLyric || jsonLyric.trim().length === 0) {
  137. return undefined;
  138. }
  139. try {
  140. // 尝试解析JSON
  141. const trimmed = jsonLyric.trim();
  142. // 检查是否是JSON格式(以[开头)
  143. if (!trimmed.startsWith('[')) {
  144. console.log("onecold LyricUtil: 歌词不是JSON格式,直接返回原歌词");
  145. return undefined;
  146. }
  147. console.log("onecold LyricUtil: 开始解析Navidrome JSON歌词");
  148. const jsonData = JSON.parse(trimmed) as NavidromeLangData[];
  149. if (!jsonData || jsonData.length === 0) {
  150. console.log("onecold LyricUtil: JSON歌词解析失败:数据为空");
  151. return undefined;
  152. }
  153. // 提取所有语言的歌词行,合并去重
  154. const allLyricLines: Map<number, string> = new Map();
  155. for (let i = 0; i < jsonData.length; i++) {
  156. const langData = jsonData[i];
  157. if (!langData.line || langData.line.length === 0) {
  158. continue;
  159. }
  160. // 遍历该语言的所有歌词行
  161. for (let j = 0; j < langData.line.length; j++) {
  162. const line = langData.line[j];
  163. if (line.start !== undefined && line.value) {
  164. // 如果该时间点还没有歌词,或者当前语言的歌词不为空,则添加
  165. const existing = allLyricLines.get(line.start);
  166. if (!existing || line.value.trim().length > 0) {
  167. allLyricLines.set(line.start, line.value);
  168. }
  169. }
  170. }
  171. }
  172. if (allLyricLines.size === 0) {
  173. console.log("onecold LyricUtil: 没有有效的歌词行");
  174. return undefined;
  175. }
  176. // 按时间戳排序
  177. const sortedTimes = Array.from(allLyricLines.keys()).sort((a, b) => a - b);
  178. // 转换为LRC格式
  179. const lrcLines: string[] = [];
  180. for (let k = 0; k < sortedTimes.length; k++) {
  181. const timeMs = sortedTimes[k];
  182. const text = allLyricLines.get(timeMs) ?? '';
  183. // 转换时间戳: 毫秒 -> [mm:ss.xx]
  184. const minutes = Math.floor(timeMs / 60000);
  185. const seconds = Math.floor((timeMs % 60000) / 1000);
  186. const centiseconds = Math.floor((timeMs % 1000) / 10);
  187. const timeTag = `[${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}.${centiseconds.toString().padStart(2, '0')}]`;
  188. lrcLines.push(`${timeTag}${text}`);
  189. }
  190. const result = lrcLines.join('\n');
  191. console.log("onecold LyricUtil: Navidrome歌词转换成功,共" + lrcLines.length + "行");
  192. console.log("onecold LyricUtil: 转换后歌词预览:" + result.substring(0, 200));
  193. return result;
  194. } catch (error) {
  195. const err = error as Error;
  196. console.log("onecold LyricUtil: Navidrome歌词转换失败: " + err.message);
  197. return undefined;
  198. }
  199. }
  200. /**
  201. * 根据当前播放时间获取对应的歌词行
  202. * @param lyricContent LRC格式歌词内容
  203. * @param currentTimeMs 当前播放时间(毫秒)
  204. * @returns 当前歌词行文本,如果没有则返回空字符串
  205. */
  206. public getCurrentLyricLine(lyricContent: string, currentTimeMs: number): string {
  207. if (!lyricContent || currentTimeMs < 0) {
  208. return '';
  209. }
  210. try {
  211. const lines = lyricContent.split('\n');
  212. const lyricLines: LyricLineItem[] = [];
  213. const ignoredTags = ['ti', 'ar', 'al', 'by', 'offset', 'hash', 'sign', 'qq', 'total', 'Outro', 'tool'];
  214. for (let i = 0; i < lines.length; i++) {
  215. const line = lines[i].trim();
  216. if (!line) continue;
  217. if (ignoredTags.some(tag => line.startsWith(`[${tag}]`))) continue;
  218. // 匹配标准LRC时间戳 [mm:ss.xx] 或 [mm:ss.xxx]
  219. const match = /^\[(\d{2}):(\d{2})\.(\d{2,3})\](.*)$/.exec(line);
  220. if (match) {
  221. const minutes = parseInt(match[1], 10);
  222. const seconds = parseInt(match[2], 10);
  223. const fraction = parseInt(match[3], 10);
  224. const milliseconds = match[3].length === 2 ? fraction * 10 : fraction;
  225. const timeMs = minutes * 60000 + seconds * 1000 + milliseconds;
  226. const text = match[4].trim();
  227. if (text) {
  228. const item: LyricLineItem = { time: timeMs, text: text };
  229. lyricLines.push(item);
  230. }
  231. }
  232. }
  233. if (lyricLines.length === 0) {
  234. return '';
  235. }
  236. // 按时间排序
  237. lyricLines.sort((a, b) => a.time - b.time);
  238. // 找到当前时间对应的歌词行
  239. let currentLyric = '';
  240. for (let i = lyricLines.length - 1; i >= 0; i--) {
  241. if (currentTimeMs >= lyricLines[i].time) {
  242. currentLyric = lyricLines[i].text;
  243. break;
  244. }
  245. }
  246. return currentLyric;
  247. } catch (error) {
  248. console.warn('getCurrentLyricLine error:', (error as Error).message);
  249. return '';
  250. }
  251. }
  252. }
  253. export default new LyricUtil();