xref: /MusicFree/src/utils/lrcParser.ts (revision 437330137ec34d17508be0462e27fb68a4cf66d5)
1const timeReg = /\[[\d:.]+\]/g;
2const metaReg = /\[(.+):(.+)\]/g;
3
4type LyricMeta = Record<string, any>;
5
6interface IOptions {
7    musicItem?: IMusic.IMusicItem;
8    lyricSource?: ILyric.ILyricSource;
9    translation?: string;
10    extra?: Record<string, any>;
11}
12
13export interface IParsedLrcItem {
14    /** 时间 s */
15    time: number;
16    /** 歌词 */
17    lrc: string;
18    /** 翻译 */
19    translation?: string;
20    /** 位置 */
21    index: number;
22}
23
24export default class LyricParser {
25    private _musicItem?: IMusic.IMusicItem;
26
27    private meta: LyricMeta;
28    private lrcItems: Array<IParsedLrcItem>;
29
30    private extra: Record<string, any>;
31
32    private lastSearchIndex = 0;
33
34    public hasTranslation = false;
35    public lyricSource?: ILyric.ILyricSource;
36
37    get musicItem() {
38        return this._musicItem;
39    }
40
41    constructor(raw: string, options?: IOptions) {
42        // init
43        this._musicItem = options?.musicItem;
44        this.extra = options?.extra || {};
45        this.lyricSource = options?.lyricSource;
46
47        let translation = options?.translation;
48        if (!raw && translation) {
49            raw = translation;
50            translation = undefined;
51        }
52
53        const {lrcItems, meta} = this.parseLyricImpl(raw);
54        if (this.extra.offset) {
55            meta.offset = (meta.offset ?? 0) + this.extra.offset;
56        }
57        this.meta = meta;
58        this.lrcItems = lrcItems;
59
60        if (translation) {
61            this.hasTranslation = true;
62            const transLrcItems = this.parseLyricImpl(translation).lrcItems;
63
64            // 2 pointer
65            let p1 = 0;
66            let p2 = 0;
67
68            while (p1 < this.lrcItems.length) {
69                const lrcItem = this.lrcItems[p1];
70                while (
71                    transLrcItems[p2].time < lrcItem.time &&
72                    p2 < transLrcItems.length - 1
73                ) {
74                    ++p2;
75                }
76                if (transLrcItems[p2].time === lrcItem.time) {
77                    lrcItem.translation = transLrcItems[p2].lrc;
78                } else {
79                    lrcItem.translation = '';
80                }
81
82                ++p1;
83            }
84        }
85    }
86
87    getPosition(position: number): IParsedLrcItem | null {
88        position = position - (this.meta?.offset ?? 0);
89        let index;
90        /** 最前面 */
91        if (!this.lrcItems[0] || position < this.lrcItems[0].time) {
92            this.lastSearchIndex = 0;
93            return null;
94        }
95        for (
96            index = this.lastSearchIndex;
97            index < this.lrcItems.length - 1;
98            ++index
99        ) {
100            if (
101                position >= this.lrcItems[index].time &&
102                position < this.lrcItems[index + 1].time
103            ) {
104                this.lastSearchIndex = index;
105                return this.lrcItems[index];
106            }
107        }
108
109        for (index = 0; index < this.lastSearchIndex; ++index) {
110            if (
111                position >= this.lrcItems[index].time &&
112                position < this.lrcItems[index + 1].time
113            ) {
114                this.lastSearchIndex = index;
115                return this.lrcItems[index];
116            }
117        }
118
119        index = this.lrcItems.length - 1;
120        this.lastSearchIndex = index;
121        return this.lrcItems[index];
122    }
123
124    getLyricItems() {
125        return this.lrcItems;
126    }
127
128    getMeta() {
129        return this.meta;
130    }
131
132    toString(options?: {
133        withTimestamp?: boolean;
134        type?: 'raw' | 'translation';
135    }) {
136        const {type = 'raw', withTimestamp = true} = options || {};
137
138        if (withTimestamp) {
139            return this.lrcItems
140                .map(
141                    item =>
142                        `${this.timeToLrctime(item.time)} ${
143                            type === 'raw' ? item.lrc : item.translation
144                        }`,
145                )
146                .join('\r\n');
147        } else {
148            return this.lrcItems
149                .map(item => (type === 'raw' ? item.lrc : item.translation))
150                .join('\r\n');
151        }
152    }
153
154    /** [xx:xx.xx] => x s */
155    private parseTime(timeStr: string): number {
156        let result = 0;
157        const nums = timeStr.slice(1, timeStr.length - 1).split(':');
158        for (let i = 0; i < nums.length; ++i) {
159            result = result * 60 + +nums[i];
160        }
161        return result;
162    }
163    /** x s => [xx:xx.xx] */
164    private timeToLrctime(sec: number) {
165        const min = Math.floor(sec / 60);
166        sec = sec - min * 60;
167        const secInt = Math.floor(sec);
168        const secFloat = sec - secInt;
169        return `[${min.toFixed(0).padStart(2, '0')}:${secInt
170            .toString()
171            .padStart(2, '0')}.${secFloat.toFixed(2).slice(2)}]`;
172    }
173
174    private parseMetaImpl(metaStr: string) {
175        if (metaStr === '') {
176            return {};
177        }
178        const metaArr = metaStr.match(metaReg) ?? [];
179        const meta: any = {};
180        let k, v;
181        for (const m of metaArr) {
182            k = m.substring(1, m.indexOf(':'));
183            v = m.substring(k.length + 2, m.length - 1);
184            if (k === 'offset') {
185                meta[k] = +v / 1000;
186            } else {
187                meta[k] = v;
188            }
189        }
190        return meta;
191    }
192
193    private parseLyricImpl(raw: string) {
194        raw = raw.trim();
195        const rawLrcItems: Array<IParsedLrcItem> = [];
196        const rawLrcs = raw.split(timeReg) ?? [];
197        const rawTimes = raw.match(timeReg) ?? [];
198        const len = rawTimes.length;
199
200        const meta = this.parseMetaImpl(rawLrcs[0].trim());
201        rawLrcs.shift();
202
203        let counter = 0;
204        let j, lrc;
205        for (let i = 0; i < len; ++i) {
206            counter = 0;
207            while (rawLrcs[0] === '') {
208                ++counter;
209                rawLrcs.shift();
210            }
211            lrc = rawLrcs[0]?.trim?.() ?? '';
212            for (j = i; j < i + counter; ++j) {
213                rawLrcItems.push({
214                    time: this.parseTime(rawTimes[j]),
215                    lrc,
216                    index: j,
217                });
218            }
219            i += counter;
220            if (i < len) {
221                rawLrcItems.push({
222                    time: this.parseTime(rawTimes[i]),
223                    lrc,
224                    index: j,
225                });
226            }
227            rawLrcs.shift();
228        }
229        let lrcItems = rawLrcItems.sort((a, b) => a.time - b.time);
230        if (lrcItems.length === 0 && raw.length) {
231            lrcItems = raw.split('\n').map((_, index) => ({
232                time: 0,
233                lrc: _,
234                index,
235            }));
236        }
237
238        return {
239            lrcItems,
240            meta,
241        };
242    }
243}
244