Skip to content

Commit 8b7e70a

Browse files
author
Álvaro Velad Galván
authored
feat(text): Add LyRiCs (LRC) support (#3036)
Format info: https://en.wikipedia.org/wiki/LRC_(file_format)
1 parent df74eab commit 8b7e70a

File tree

6 files changed

+291
-11
lines changed

6 files changed

+291
-11
lines changed

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,8 @@ Shaka Player supports:
209209
- With help from [mux.js][] v5.7.0+, supported embedded in TS
210210
- SubRip (SRT)
211211
- UTF-8 encoding only
212+
- LyRiCs (LRC)
213+
- UTF-8 encoding only
212214

213215
Subtitles are rendered by the browser by default. Applications can create a
214216
[text display plugin][] for customer rendering to go beyond browser-supported

build/types/text

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
# Optional plugins related to text parsing and displaying.
22

3+
+../../lib/text/lrc_text_parser.js
34
+../../lib/text/mp4_ttml_parser.js
45
+../../lib/text/mp4_vtt_parser.js
56
+../../lib/text/srt_text_parser.js

lib/player.js

Lines changed: 27 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3887,6 +3887,7 @@ shaka.Player = class extends shaka.util.FakeEventTarget {
38873887
'vtt': 'text/vtt',
38883888
'webvtt': 'text/vtt',
38893889
'ttml': 'application/ttml+xml',
3890+
'lrc': 'application/x-subtitle-lrc',
38903891
}[extension];
38913892

38923893
if (!mimeType) {
@@ -4020,6 +4021,7 @@ shaka.Player = class extends shaka.util.FakeEventTarget {
40204021
'vtt': 'text/vtt',
40214022
'webvtt': 'text/vtt',
40224023
'ttml': 'application/ttml+xml',
4024+
'lrc': 'application/x-subtitle-lrc',
40234025
}[extension];
40244026

40254027
if (!mimeType) {
@@ -4050,10 +4052,10 @@ shaka.Player = class extends shaka.util.FakeEventTarget {
40504052
try {
40514053
goog.asserts.assert(
40524054
this.networkingEngine_, 'Need networking engine.');
4053-
const stringData = await this.getTextData_(uri,
4055+
const data = await this.getTextData_(uri,
40544056
this.networkingEngine_,
40554057
this.config_.streaming.retryParameters);
4056-
const vvtText = this.convertToWebVTT_(stringData, mimeType);
4058+
const vvtText = this.convertToWebVTT_(data, mimeType);
40574059
const blob = new Blob([vvtText], {type: 'text/vtt'});
40584060
uri = URL.createObjectURL(blob);
40594061
mimeType = 'text/vtt';
@@ -4147,7 +4149,7 @@ shaka.Player = class extends shaka.util.FakeEventTarget {
41474149
* @param {string} uri
41484150
* @param {!shaka.net.NetworkingEngine} netEngine
41494151
* @param {shaka.extern.RetryParameters} retryParams
4150-
* @return {!Promise.<string>}
4152+
* @return {!Promise.<BufferSource>}
41514153
* @private
41524154
*/
41534155
async getTextData_(uri, netEngine, retryParams) {
@@ -4158,14 +4160,14 @@ shaka.Player = class extends shaka.util.FakeEventTarget {
41584160

41594161
const response = await netEngine.request(type, request).promise;
41604162

4161-
return shaka.util.StringUtils.fromUTF8(response.data);
4163+
return response.data;
41624164
}
41634165

41644166

41654167
/**
41664168
* Converts an input string to a WebVTT format string.
41674169
*
4168-
* @param {string} data
4170+
* @param {BufferSource} data
41694171
* @param {string} mimeType
41704172
* @return {string}
41714173
* @private
@@ -4175,13 +4177,27 @@ shaka.Player = class extends shaka.util.FakeEventTarget {
41754177
if (mimeType === 'text/srt') {
41764178
const SrtTextParser = shaka.text.SrtTextParser;
41774179
if (SrtTextParser) {
4178-
return SrtTextParser.srt2webvtt(data);
4180+
const string = shaka.util.StringUtils.fromUTF8(data);
4181+
return SrtTextParser.srt2webvtt(string);
4182+
} else {
4183+
throw new shaka.util.Error(
4184+
shaka.util.Error.Severity.CRITICAL,
4185+
shaka.util.Error.Category.TEXT,
4186+
shaka.util.Error.Code.MISSING_TEXT_PLUGIN,
4187+
mimeType);
4188+
}
4189+
}
4190+
if (mimeType === 'application/x-subtitle-lrc') {
4191+
const LrcTextParser = shaka.text.LrcTextParser;
4192+
if (LrcTextParser) {
4193+
return LrcTextParser.convertToWebVTT(data, this.video_.duration);
4194+
} else {
4195+
throw new shaka.util.Error(
4196+
shaka.util.Error.Severity.CRITICAL,
4197+
shaka.util.Error.Category.TEXT,
4198+
shaka.util.Error.Code.MISSING_TEXT_PLUGIN,
4199+
mimeType);
41794200
}
4180-
throw new shaka.util.Error(
4181-
shaka.util.Error.Severity.CRITICAL,
4182-
shaka.util.Error.Category.TEXT,
4183-
shaka.util.Error.Code.MISSING_TEXT_PLUGIN,
4184-
mimeType);
41854201
}
41864202
throw new shaka.util.Error(
41874203
shaka.util.Error.Severity.RECOVERABLE,

lib/text/lrc_text_parser.js

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
/*! @license
2+
* Shaka Player
3+
* Copyright 2016 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
goog.provide('shaka.text.LrcTextParser');
8+
9+
goog.require('goog.asserts');
10+
goog.require('shaka.log');
11+
goog.require('shaka.text.Cue');
12+
goog.require('shaka.text.TextEngine');
13+
goog.require('shaka.util.StringUtils');
14+
15+
16+
/**
17+
* LRC file format: https://en.wikipedia.org/wiki/LRC_(file_format)
18+
*
19+
* @implements {shaka.extern.TextParser}
20+
* @export
21+
*/
22+
shaka.text.LrcTextParser = class {
23+
/**
24+
* @override
25+
* @export
26+
*/
27+
parseInit(data) {
28+
goog.asserts.assert(false, 'LRC does not have init segments');
29+
}
30+
31+
/**
32+
* @override
33+
* @export
34+
*/
35+
parseMedia(data, time) {
36+
return shaka.text.LrcTextParser.getCues_(data, time.segmentEnd);
37+
}
38+
39+
/**
40+
* @param {BufferSource} data
41+
* @param {number} segmentEnd
42+
* @return {!Array.<!shaka.extern.Cue>}
43+
* @private
44+
*/
45+
static getCues_(data, segmentEnd) {
46+
const StringUtils = shaka.util.StringUtils;
47+
const LrcTextParser = shaka.text.LrcTextParser;
48+
49+
// Get the input as a string.
50+
const str = StringUtils.fromUTF8(data);
51+
52+
/** @type {shaka.extern.Cue} */
53+
let prevCue = null;
54+
55+
/** @type {!Array.<!shaka.extern.Cue>} */
56+
const cues = [];
57+
const lines = str.split(/\r?\n/);
58+
for (const line of lines) {
59+
if (!line || /^\s+$/.test(line)) {
60+
continue;
61+
}
62+
63+
// LRC content
64+
const match = LrcTextParser.lyricLine_.exec(line);
65+
if (match) {
66+
const startTime = LrcTextParser.parseTime_(match[1]);
67+
// This time can be overwritten by a subsequent cue.
68+
// By default we add 2 seconds of duration.
69+
const endTime = segmentEnd ? segmentEnd : startTime + 2;
70+
const payload = match[2];
71+
const cue = new shaka.text.Cue(startTime, endTime, payload);
72+
73+
// Update previous
74+
if (prevCue) {
75+
prevCue.endTime = startTime;
76+
cues.push(prevCue);
77+
}
78+
prevCue = cue;
79+
continue;
80+
}
81+
shaka.log.warning('LrcTextParser encountered an unknown line.', line);
82+
}
83+
if (prevCue) {
84+
cues.push(prevCue);
85+
}
86+
87+
return cues;
88+
}
89+
90+
/**
91+
* Parses a LRC time from the given parser.
92+
*
93+
* @param {string} string
94+
* @return {number}
95+
* @private
96+
*/
97+
static parseTime_(string) {
98+
const LrcTextParser = shaka.text.LrcTextParser;
99+
const match = LrcTextParser.timeFormat_.exec(string);
100+
const minutes = parseInt(match[1], 10);
101+
const seconds = parseFloat(match[2].replace(',', '.'));
102+
return minutes * 60 + seconds;
103+
}
104+
105+
/**
106+
* Convert a LRC input to WebVTT
107+
*
108+
* @param {BufferSource} data
109+
* @param {number} segmentEnd
110+
* @return {string}
111+
* @export
112+
*/
113+
static convertToWebVTT(data, segmentEnd) {
114+
const LrcTextParser = shaka.text.LrcTextParser;
115+
const cues = LrcTextParser.getCues_(data, segmentEnd);
116+
let webvttString = 'WEBVTT\n\n';
117+
for (const cue of cues) {
118+
const webvttTimeString = (time) => {
119+
const hours = Math.floor(time / 3600);
120+
const minutes = Math.floor(time / 60 % 60);
121+
const seconds = Math.floor(time % 60);
122+
const milliseconds = Math.floor(time * 1000 % 1000);
123+
return (hours < 10 ? '0' : '') + hours + ':' +
124+
(minutes < 10 ? '0' : '') + minutes + ':' +
125+
(seconds < 10 ? '0' : '') + seconds + '.' +
126+
(milliseconds < 100 ? (milliseconds < 10 ? '00' : '0') : '') +
127+
milliseconds;
128+
};
129+
webvttString += webvttTimeString(cue.startTime) + ' --> ' +
130+
webvttTimeString(cue.endTime) + '\n';
131+
webvttString += cue.payload + '\n\n';
132+
}
133+
return webvttString;
134+
}
135+
};
136+
137+
/**
138+
* @const
139+
* @private {!RegExp}
140+
* @example [00:12.0]Text or [00:12.00]Text or [00:12.000]Text or
141+
* [00:12,0]Text or [00:12,00]Text or [00:12,000]Text
142+
*/
143+
shaka.text.LrcTextParser.lyricLine_ =
144+
/^\[(\d{1,2}:\d{1,2}(?:[.,]\d{1,3})?)\](.*)/;
145+
146+
/**
147+
* @const
148+
* @private {!RegExp}
149+
* @example 00:12.0 or 00:12.00 or 00:12.000 or
150+
* 00:12,0 or 00:12,00 or 00:12,000
151+
*/
152+
shaka.text.LrcTextParser.timeFormat_ =
153+
/^(\d+):(\d{1,2}(?:[.,]\d{1,3})?)$/;
154+
155+
shaka.text.TextEngine.registerParser(
156+
'application/x-subtitle-lrc', () => new shaka.text.LrcTextParser());

shaka-player.uncompiled.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ goog.require('shaka.polyfill.VideoPlaybackQuality');
4949
goog.require('shaka.polyfill');
5050
goog.require('shaka.routing.Walker');
5151
goog.require('shaka.text.Cue');
52+
goog.require('shaka.text.LrcTextParser');
5253
goog.require('shaka.text.Mp4TtmlParser');
5354
goog.require('shaka.text.Mp4VttParser');
5455
goog.require('shaka.text.TextEngine');

test/text/lrc_text_parser_unit.js

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
/*! @license
2+
* Shaka Player
3+
* Copyright 2016 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
goog.require('shaka.text.LrcTextParser');
8+
goog.require('shaka.util.BufferUtils');
9+
goog.require('shaka.util.StringUtils');
10+
11+
describe('LrcTextParser', () => {
12+
it('supports no cues', () => {
13+
verifyHelper([],
14+
'',
15+
{periodStart: 0, segmentStart: 0, segmentEnd: 0});
16+
});
17+
18+
it('handles a blank line at the start of the file', () => {
19+
verifyHelper(
20+
[
21+
{startTime: 0, endTime: 2, payload: 'Test'},
22+
],
23+
'\n\n' +
24+
'[00:00.00]Test',
25+
{periodStart: 0, segmentStart: 0, segmentEnd: 0});
26+
});
27+
28+
it('handles a blank line at the end of the file', () => {
29+
verifyHelper(
30+
[
31+
{startTime: 0, endTime: 2, payload: 'Test'},
32+
],
33+
'[00:00.00]Test' +
34+
'\n\n',
35+
{periodStart: 0, segmentStart: 0, segmentEnd: 0});
36+
});
37+
38+
it('handles no blank line at the end of the file', () => {
39+
verifyHelper(
40+
[
41+
{startTime: 0, endTime: 2, payload: 'Test'},
42+
],
43+
'[00:00.00]Test',
44+
{periodStart: 0, segmentStart: 0, segmentEnd: 0,
45+
});
46+
});
47+
48+
it('supports multiple cues', () => {
49+
verifyHelper(
50+
[
51+
{startTime: 0, endTime: 10, payload: 'Test'},
52+
{startTime: 10, endTime: 20, payload: 'Test2'},
53+
{startTime: 20, endTime: 22, payload: 'Test3'},
54+
],
55+
'[00:00.00]Test\n' +
56+
'[00:10.00]Test2\n' +
57+
'[00:20.00]Test3',
58+
{periodStart: 0, segmentStart: 0, segmentEnd: 0});
59+
});
60+
61+
it('supports different time formats', () => {
62+
verifyHelper(
63+
[
64+
{startTime: 0.1, endTime: 10.001, payload: 'Test'},
65+
{startTime: 10.001, endTime: 20.02, payload: 'Test2'},
66+
{startTime: 20.02, endTime: 30.1, payload: 'Test3'},
67+
{startTime: 30.1, endTime: 40.001, payload: 'Test4'},
68+
{startTime: 40.001, endTime: 50.02, payload: 'Test5'},
69+
{startTime: 50.02, endTime: 52.02, payload: 'Test6'},
70+
],
71+
'[00:00.1]Test\n' +
72+
'[00:10.001]Test2\n' +
73+
'[00:20.02]Test3\n' +
74+
'[00:30,1]Test4\n' +
75+
'[00:40,001]Test5\n' +
76+
'[00:50,02]Test6',
77+
{periodStart: 0, segmentStart: 0, segmentEnd: 0});
78+
});
79+
80+
/**
81+
* @param {!Array} cues
82+
* @param {string} text
83+
* @param {shaka.extern.TextParser.TimeContext} time
84+
*/
85+
function verifyHelper(cues, text, time) {
86+
const BufferUtils = shaka.util.BufferUtils;
87+
const StringUtils = shaka.util.StringUtils;
88+
89+
const data = BufferUtils.toUint8(StringUtils.toUTF8(text));
90+
91+
const parser = new shaka.text.LrcTextParser();
92+
const result = parser.parseMedia(data, time);
93+
94+
const expected = cues.map((cue) => {
95+
if (cue.nestedCues) {
96+
cue.nestedCues = cue.nestedCues.map(
97+
(nestedCue) => jasmine.objectContaining(nestedCue)
98+
);
99+
}
100+
return jasmine.objectContaining(cue);
101+
});
102+
expect(result).toEqual(expected);
103+
}
104+
});

0 commit comments

Comments
 (0)