From f33ca0cdbb7f2423889e8e6fc572874f8b16b922 Mon Sep 17 00:00:00 2001 From: Cloud Date: Sun, 2 Aug 2026 21:26:10 +0800 Subject: [PATCH] feat: support word-by-word lyric parsing and highlighting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add LyricWord struct for per-word timing data - Parser supports LRC word-by-word format (e.g. [00:27.94]持[00:28.26]有) - Fix centisecond parsing to correctly convert 1/100s to milliseconds - LyricPage/LyricRect add word-by-word rendering with per-word highlight - Presenter passes word timing data to QML layer - Add related unit tests feat: 支持逐字歌词解析与高亮显示 - 新增 LyricWord 结构体,用于存储逐字歌词的时间轴数据 - 解析器支持 LRC 逐字格式(如 [00:27.94]持[00:28.26]有) - 修复厘秒解析,正确将百分之一秒转换为毫秒 - LyricPage/LyricRect 添加逐字歌词渲染逻辑,支持逐字高亮 - Presenter 传递逐字歌词数据到 QML 层 - 添加相关单元测试 --- src/libdmusic/core/lyricanalysis.cpp | 104 +++++++++++++-- src/libdmusic/core/lyricanalysis.h | 14 +- src/libdmusic/presenter.cpp | 25 +++- src/music-player/lyric/LyricPage.qml | 21 ++- src/music-player/lyric/LyricRect.qml | 139 ++++++++++++-------- tests/libdmusic-test/test_lyricanalysis.cpp | 94 ++++++++++++- 6 files changed, 316 insertions(+), 81 deletions(-) diff --git a/src/libdmusic/core/lyricanalysis.cpp b/src/libdmusic/core/lyricanalysis.cpp index 3ef774f0f..952897461 100644 --- a/src/libdmusic/core/lyricanalysis.cpp +++ b/src/libdmusic/core/lyricanalysis.cpp @@ -18,6 +18,21 @@ DCORE_USE_NAMESPACE +// 解析 LRC 时间戳,正确处理厘秒(百分之一秒)格式 +static qint64 parseTimeStamp(const QString &timeStr) +{ + QStringList timeParts = timeStr.split('.'); + QTime t = QTime::fromString(timeParts[0], "mm:ss"); + if (!t.isValid()) return -1; + qint64 time = t.msecsSinceStartOfDay(); + if (timeParts.size() > 1) { + QString frac = timeParts[1]; + if (frac.length() == 2) time += frac.toInt() * 10; // 厘秒转毫秒 + else if (frac.length() == 3) time += frac.toInt(); // 已经是毫秒 + } + return time; +} + static float codecConfidenceForData(const QTextCodec *codec, const QByteArray &data, const QLocale::Country &country) { qreal hep_count = 0; @@ -103,7 +118,7 @@ static float codecConfidenceForData(const QTextCodec *codec, const QByteArray &d return qMax(0.0f, c); } -LyricAnalysis::LyricAnalysis(): m_offset(0.00) +LyricAnalysis::LyricAnalysis() { qCDebug(dmMusic) << "LyricAnalysis constructor called"; } @@ -139,29 +154,80 @@ void LyricAnalysis::parseLyric(const QString &str) qCDebug(dmMusic) << "Parsing lyrics with length:" << str.length(); auto lines = str.split("\n"); QRegExp rx("\\[([^\\]]*)\\]\\s*(\\S.*\\S|\\S)\\s*$"); + // 用于检测时间戳数量的正则(不含行尾锚点) + QRegExp timestampRx("\\[\\d{2}:\\d{2}\\.\\d{2,3}\\]"); + // 匹配逐字歌词中单个 [时间]文本 片段 + QRegExp wordRx("\\[([^\\]]+)\\]([^\\[]*)"); QVector> tmp; + QVector> tmpWord; for (auto line : lines) { - if (rx.indexIn(line) != -1) { - auto timeStr = rx.capturedTexts()[1]; - auto lyricStr = rx.capturedTexts()[2]; - QTime t = QTime::fromString(timeStr, "mm:ss.z"); - qint64 time = t.msecsSinceStartOfDay(); - if (t.isValid()) { - tmp.push_back({time, lyricStr}); - qCDebug(dmMusic) << "Parsed lyric - Time:" << timeStr << "Text:" << lyricStr; + // 检测是否包含多个时间戳(逐字歌词格式) + int timestampCount = 0; + int pos = 0; + while ((pos = timestampRx.indexIn(line, pos)) != -1) { + timestampCount++; + pos += timestampRx.matchedLength(); + } + + if (timestampCount > 1) { + // 逐字歌词格式:[mm:ss.xx]字[mm:ss.xx]字... + QVector words; + qint64 firstTime = -1; + QString fullText; + pos = 0; + while ((pos = wordRx.indexIn(line, pos)) != -1) { + auto timeStr = wordRx.capturedTexts()[1]; + auto textStr = wordRx.capturedTexts()[2]; + qint64 time = parseTimeStamp(timeStr); + if (time >= 0) { + if (firstTime < 0) firstTime = time; + words.push_back({time, textStr}); + fullText += textStr; + qCDebug(dmMusic) << "Parsed word - Time:" << timeStr << "ms:" << time << "Text:" << textStr; + } + pos += wordRx.matchedLength(); + } + if (!words.isEmpty() && firstTime >= 0) { + tmp.push_back({firstTime, fullText}); + tmpWord.push_back(words); + qCDebug(dmMusic) << "Parsed lyric line with" << words.size() << "words - Time:" << firstTime << "Text:" << fullText; + } else { + qCWarning(dmMusic) << "Invalid multi-timestamp lyric line:" << line; + } + } else if (timestampCount == 1) { + // 普通歌词格式:[mm:ss.xx]歌词文本 + if (rx.indexIn(line) != -1) { + auto timeStr = rx.capturedTexts()[1]; + auto lyricStr = rx.capturedTexts()[2]; + qint64 time = parseTimeStamp(timeStr); + if (time >= 0) { + tmp.push_back({time, lyricStr}); + tmpWord.push_back(QVector()); // 空表示无逐字时间轴 + qCDebug(dmMusic) << "Parsed lyric - Time:" << timeStr << "Text:" << lyricStr; + } else { + qCWarning(dmMusic) << "Invalid time format in lyric line:" << line; + } } else { - qCWarning(dmMusic) << "Invalid time format in lyric line:" << line; + qCDebug(dmMusic) << "Skipping non-matching line:" << line; } } else { qCDebug(dmMusic) << "Skipping non-matching line:" << line; } } - std::sort(tmp.begin(), tmp.end()); + // 按时间排序(同时保持 word 和 lyric 的对应关系) + QVector indices(tmp.size()); + for (int i = 0; i < indices.size(); i++) indices[i] = i; + std::sort(indices.begin(), indices.end(), [&](int a, int b) { + return tmp[a].first < tmp[b].first; + }); + m_allLyrics.clear(); - for (auto item : tmp) { - m_allLyrics.push_back(item); + m_wordLyrics.clear(); + for (int i : indices) { + m_allLyrics.push_back(tmp[i]); + m_wordLyrics.push_back(tmpWord[i]); } } @@ -269,3 +335,15 @@ int LyricAnalysis::getCount() const qCDebug(dmMusic) << "Getting lyrics count:" << m_allLyrics.size(); return m_allLyrics.count(); } + +bool LyricAnalysis::hasWordTiming(int index) const +{ + if (index < 0 || index >= m_wordLyrics.size()) return false; + return !m_wordLyrics[index].isEmpty(); +} + +QVector LyricAnalysis::getWordTiming(int index) const +{ + if (index < 0 || index >= m_wordLyrics.size()) return QVector(); + return m_wordLyrics[index]; +} diff --git a/src/libdmusic/core/lyricanalysis.h b/src/libdmusic/core/lyricanalysis.h index 3133b229d..238c5ba81 100644 --- a/src/libdmusic/core/lyricanalysis.h +++ b/src/libdmusic/core/lyricanalysis.h @@ -1,5 +1,4 @@ -// Copyright (C) 2020 ~ 2020 Deepin Technology Co., Ltd. -// SPDX-FileCopyrightText: 2023 UnionTech Software Technology Co., Ltd. +// SPDX-FileCopyrightText: 2023 - 2026 UnionTech Software Technology Co., Ltd. // // SPDX-License-Identifier: GPL-3.0-or-later @@ -10,6 +9,11 @@ #include #include +struct LyricWord { + qint64 time; // 时间戳(毫秒) + QString text; // 该时间点后的文本片段 +}; + class LyricAnalysis { public: @@ -22,14 +26,18 @@ class LyricAnalysis int getIndex(qint64 pos); qint64 getPostion(int index); + // 逐字歌词支持 + bool hasWordTiming(int index) const; + QVector getWordTiming(int index) const; + private: void parseLyric(const QString &str); QString getFileCodec(); private: QString m_filePath; - double m_offset; QVector > m_allLyrics; + QVector> m_wordLyrics; // 逐字歌词时间轴 }; #endif diff --git a/src/libdmusic/presenter.cpp b/src/libdmusic/presenter.cpp index 381ff3bdc..c210e7308 100644 --- a/src/libdmusic/presenter.cpp +++ b/src/libdmusic/presenter.cpp @@ -1,5 +1,4 @@ -// Copyright (C) 2020 ~ 2026 Uniontech Software Technology Co., Ltd. -// SPDX-FileCopyrightText: 2023 UnionTech Software Technology Co., Ltd. +// SPDX-FileCopyrightText: 2023 - 2026 UnionTech Software Technology Co., Ltd. // // SPDX-License-Identifier: GPL-3.0-or-later @@ -328,10 +327,26 @@ QVariantList Presenter::getLyrics() } m_data->m_lyricAnalysis.setFromFile(lrcPath); QVector > allLyrics = m_data->m_lyricAnalysis.allLyrics(); - for (QPair lyric : allLyrics) { + for (int i = 0; i < allLyrics.size(); i++) { QVariantMap curData; - curData.insert("time", lyric.first); - curData.insert("lyric", lyric.second); + curData.insert("time", allLyrics[i].first); + curData.insert("lyric", allLyrics[i].second); + + // 逐字歌词时间轴 + bool hasWords = m_data->m_lyricAnalysis.hasWordTiming(i); + curData.insert("hasWordTiming", hasWords); + if (hasWords) { + QVariantList wordsList; + QVector words = m_data->m_lyricAnalysis.getWordTiming(i); + for (const LyricWord &w : words) { + QVariantMap wordMap; + wordMap.insert("time", w.time); + wordMap.insert("text", w.text); + wordsList.append(wordMap); + } + curData.insert("words", wordsList); + } + lyrics.append(curData); } } diff --git a/src/music-player/lyric/LyricPage.qml b/src/music-player/lyric/LyricPage.qml index 1a8a6eb59..b906fa1ba 100644 --- a/src/music-player/lyric/LyricPage.qml +++ b/src/music-player/lyric/LyricPage.qml @@ -1,4 +1,4 @@ -// SPDX-FileCopyrightText: 2023 UnionTech Software Technology Co., Ltd. +// SPDX-FileCopyrightText: 2023 - 2026 UnionTech Software Technology Co., Ltd. // // SPDX-License-Identifier: GPL-3.0-or-later @@ -19,8 +19,10 @@ Rectangle{ property string bgImgPath: "qrc:/dsg/img/music.svg" property bool withLrcs: lrcModel.count == 0 ? false : true property int centerAreaWidth: 899 + property real currentPosition: 0 // property int curIndex: 0 property ListModel lrcModel: ListModel{} + property var wordLyricsData: [] signal currentIndexChanged(int index) @@ -156,6 +158,8 @@ Rectangle{ anchors.horizontalCenter: parent.horizontalCenter LyricRect { id: lyricRect + currentPosition: lrcRectItem.currentPosition + wordLyricsData: lrcRectItem.wordLyricsData } } Rectangle { @@ -221,6 +225,7 @@ Rectangle{ function metaChange(){ lrcModel.clear() + wordLyricsData = [] var meta = Presenter.getActivateMeta() titleStr = meta["title"] @@ -234,9 +239,20 @@ Rectangle{ } var lyricList = Presenter.getLyrics(); + var tempWordData = []; for (var i = 0; i < lyricList.length; i++) { - lrcModel.append(lyricList[i]) + var item = lyricList[i]; + // 将 words 数据存储到单独的 JS 数组中 + var words = item["words"] || []; + tempWordData.push(words); + // 创建不包含 words 的新对象,因为 ListModel 无法正确存储嵌套数据 + lrcModel.append({ + "time": item["time"], + "lyric": item["lyric"], + "hasWordTiming": item["hasWordTiming"] + }); } + wordLyricsData = tempWordData; //切换shader switchShader(); @@ -244,6 +260,7 @@ Rectangle{ function positionChange(position, length) { position = position + 500 + currentPosition = position //二分法查找位置 var lt,rt lt = 0 diff --git a/src/music-player/lyric/LyricRect.qml b/src/music-player/lyric/LyricRect.qml index 428c114e7..4b11963b7 100644 --- a/src/music-player/lyric/LyricRect.qml +++ b/src/music-player/lyric/LyricRect.qml @@ -11,6 +11,8 @@ Rectangle { property bool isFlicking: false property int itemHeight: 45 property int highlightItemHeight: 60 + property real currentPosition: 0 + property var wordLyricsData: [] id: lyricRect width: parent.width @@ -88,9 +90,46 @@ Rectangle { width: parent ? parent.width : 0 height: itemHeight color: "#00000000" + property int lineIndex: index + property bool hasWordTiming: model !== undefined && model.hasWordTiming === true + property var lineWords: hasWordTiming ? wordLyricsData[lineIndex] : [] + + // 距离相关的透明度值 + property real distanceOpacity: { + if (lyricItemRect.ListView.isCurrentItem) { + return 1.0 + } + if (curIndex <= Math.abs(lyricRect.height / itemHeight / 2)) { + if (index > Math.abs(lyricRect.height / itemHeight) - 2) { + return 0.24 + } else if (index > Math.abs(lyricRect.height / itemHeight) - 3) { + return 0.42 + } else { + return 0.7 + } + } else if (curIndex > lrcModel.count - Math.abs(lyricRect.height / itemHeight / 2)) { + if (index < lrcModel.count - Math.abs(lyricRect.height / itemHeight) + 1) { + return 0.24 + } else if (index < lrcModel.count - Math.abs(lyricRect.height / itemHeight) + 2) { + return 0.42 + } else { + return 0.7 + } + } else { + if (Math.abs(curIndex - index) > Math.abs(lyricRect.height / itemHeight / 2) - 1) { + return 0.24 + } else if (Math.abs(curIndex - index) > Math.abs(lyricRect.height / itemHeight / 2) - 2) { + return 0.42 + } else { + return 0.7 + } + } + } + // 普通歌词(无逐字时间轴) Text { - id: txtLyric; + id: txtLyric + visible: !lyricItemRect.hasWordTiming width: parent ? parent.width : 0 anchors.left: parent ? parent.left : undefined anchors.verticalCenter: parent ? parent.verticalCenter : undefined @@ -99,76 +138,64 @@ Rectangle { text: lyric color: { - // 根据距离添加渐变 if( lyricItemRect.ListView.isCurrentItem ) { return palette.highlight } if(DTK.themeType === ApplicationHelper.LightType) - { - if (curIndex <= Math.abs(lyricRect.height / itemHeight / 2)) { //开始 - if (index > Math.abs(lyricRect.height / itemHeight) - 2) { - return Qt.rgba(0, 0, 0, 0.24) - } else if (index > Math.abs(lyricRect.height / itemHeight) - 3) { - return Qt.rgba(0, 0, 0, 0.42) - } else { - return Qt.rgba(0, 0, 0, 0.7) - } - } else if (curIndex > lrcModel.count - Math.abs(lyricRect.height / itemHeight / 2)) { //结尾 - if (index < lrcModel.count - Math.abs(lyricRect.height / itemHeight) + 1) { - return Qt.rgba(0, 0, 0, 0.24) - } else if (index < lrcModel.count - Math.abs(lyricRect.height / itemHeight) + 2) { - return Qt.rgba(0, 0, 0, 0.42) - } else { - return Qt.rgba(0, 0, 0, 0.7) - } - } else { //中间部分 - if (Math.abs(curIndex - index) > Math.abs(lyricRect.height / itemHeight / 2) - 1) { - return Qt.rgba(0, 0, 0, 0.24) - } else if (Math.abs(curIndex - index) > Math.abs(lyricRect.height / itemHeight / 2) - 2) { - return Qt.rgba(0, 0, 0, 0.42) - } else { - return Qt.rgba(0, 0, 0, 0.7) + return Qt.rgba(0, 0, 0, distanceOpacity) + else + return Qt.rgba(255,255,255, distanceOpacity) + } + //font.family: "SourceHanSansSC" + font.pixelSize: lyricItemRect.ListView.isCurrentItem ? 18 : 14 + font.weight: lyricItemRect.ListView.isCurrentItem ? Font.DemiBold : Font.Medium + } + + // 逐字歌词(有逐字时间轴) + Flow { + id: wordFlow + visible: lyricItemRect.hasWordTiming + width: parent.width + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + + Repeater { + id: wordRepeater + model: lyricItemRect.lineWords + + Text { + property bool wordSung: modelData.time <= lyricRect.currentPosition + + text: modelData.text + color: { + if (lyricItemRect.ListView.isCurrentItem) { + if (wordSung) + return palette.highlight + else + return DTK.themeType === ApplicationHelper.LightType + ? Qt.rgba(0, 0, 0, 0.4) + : Qt.rgba(255, 255, 255, 0.4) + } else { + if(DTK.themeType === ApplicationHelper.LightType) + return Qt.rgba(0, 0, 0, distanceOpacity) + else + return Qt.rgba(255,255,255, distanceOpacity) } } - } else { - if (curIndex <= Math.abs(lyricRect.height / itemHeight / 2)) { //开始 - if (index > Math.abs(lyricRect.height / itemHeight) - 2) { - return Qt.rgba(255,255,255, 0.24) - } else if (index > Math.abs(lyricRect.height / itemHeight) - 3) { - return Qt.rgba(255,255,255, 0.42) - } else { - return Qt.rgba(255,255,255, 0.7) - } - } else if (curIndex > lrcModel.count - Math.abs(lyricRect.height / itemHeight / 2)) { //结尾 - if (index < lrcModel.count - Math.abs(lyricRect.height / itemHeight) + 1) { - return Qt.rgba(255,255,255, 0.24) - } else if (index < lrcModel.count - Math.abs(lyricRect.height / itemHeight) + 2) { - return Qt.rgba(255,255,255, 0.42) - } else { - return Qt.rgba(255,255,255, 0.7) - } - } else { //中间部分 - if (Math.abs(curIndex - index) > Math.abs(lyricRect.height / itemHeight / 2) - 1) { - return Qt.rgba(255,255,255, 0.24) - } else if (Math.abs(curIndex - index) > Math.abs(lyricRect.height / itemHeight / 2) - 2) { - return Qt.rgba(255,255,255, 0.42) - } else { - return Qt.rgba(255,255,255, 0.7) - } + Behavior on color { + ColorAnimation { duration: 150 } } + font.pixelSize: lyricItemRect.ListView.isCurrentItem ? 18 : 14 + font.weight: lyricItemRect.ListView.isCurrentItem ? Font.DemiBold : Font.Medium } } - //font.family: "SourceHanSansSC" - font.pixelSize: lyricItemRect.ListView.isCurrentItem ? 18 : 14 - - font.weight: lyricItemRect.ListView.isCurrentItem ? Font.DemiBold : Font.Medium } + MouseArea { id: mouseArea anchors.fill: parent onDoubleClicked: { console.log("onDoubleClicked: index:" + index) - //isFlicking = false curIndex = index var time = lrcModel.get(curIndex)["time"] Presenter.setPosition(time) diff --git a/tests/libdmusic-test/test_lyricanalysis.cpp b/tests/libdmusic-test/test_lyricanalysis.cpp index 2450e4d84..55024dfac 100644 --- a/tests/libdmusic-test/test_lyricanalysis.cpp +++ b/tests/libdmusic-test/test_lyricanalysis.cpp @@ -1,5 +1,4 @@ -// Copyright (C) 2020 ~ 2021 Uniontech Software Technology Co., Ltd. -// SPDX-FileCopyrightText: 2023 UnionTech Software Technology Co., Ltd. +// SPDX-FileCopyrightText: 2023 - 2026 UnionTech Software Technology Co., Ltd. // // SPDX-License-Identifier: GPL-3.0-or-later @@ -248,3 +247,94 @@ TEST(LyricAnalysisTest, getPostionOnEmptyLyricsReturnsZero) EXPECT_EQ(la.getPostion(0), 0); EXPECT_EQ(la.getPostion(999), 0); } + +// ============================================================================ +// 逐字歌词(多时间戳格式)解析 +// ============================================================================ +TEST(LyricAnalysisTest, parsesMultiTimestampLyrics) +{ + TempLrcFile tempLrc("[00:27.94]持[00:28.26]有[00:28.54]一[00:28.77]半[00:31.03]\n"); + LyricAnalysis la; + la.setFromFile(tempLrc.path()); + ASSERT_EQ(la.getCount(), 1); + EXPECT_EQ(la.getLineAt(0), QStringLiteral("持有一半")); + EXPECT_TRUE(la.hasWordTiming(0)); + EXPECT_EQ(la.getWordTiming(0).size(), 5); +} + +TEST(LyricAnalysisTest, wordTimingHasCorrectTimes) +{ + TempLrcFile tempLrc("[00:27.94]持[00:28.26]有[00:28.54]一[00:28.77]半[00:31.03]\n"); + LyricAnalysis la; + la.setFromFile(tempLrc.path()); + auto words = la.getWordTiming(0); + ASSERT_EQ(words.size(), 5); + EXPECT_EQ(words[0].time, 27940); + EXPECT_EQ(words[0].text, QStringLiteral("持")); + EXPECT_EQ(words[1].time, 28260); + EXPECT_EQ(words[1].text, QStringLiteral("有")); + EXPECT_EQ(words[2].time, 28540); + EXPECT_EQ(words[2].text, QStringLiteral("一")); + EXPECT_EQ(words[3].time, 28770); + EXPECT_EQ(words[3].text, QStringLiteral("半")); + EXPECT_EQ(words[4].time, 31030); + EXPECT_EQ(words[4].text, QStringLiteral("")); +} + +TEST(LyricAnalysisTest, simpleLyricsHaveNoWordTiming) +{ + TempLrcFile tempLrc("[00:01.20]first line\n[00:03.50]second line\n"); + LyricAnalysis la; + la.setFromFile(tempLrc.path()); + EXPECT_FALSE(la.hasWordTiming(0)); + EXPECT_FALSE(la.hasWordTiming(1)); + EXPECT_TRUE(la.getWordTiming(0).isEmpty()); +} + +TEST(LyricAnalysisTest, mixedFormatLyrics) +{ + // 混合格式:普通歌词 + 逐字歌词 + TempLrcFile tempLrc( + "[00:01.00]普通歌词\n" + "[00:05.00]逐[00:05.20]字[00:05.40]歌[00:05.60]词\n" + "[00:10.00]另一行普通歌词\n"); + LyricAnalysis la; + la.setFromFile(tempLrc.path()); + ASSERT_EQ(la.getCount(), 3); + + // 第一行:普通歌词 + EXPECT_FALSE(la.hasWordTiming(0)); + EXPECT_EQ(la.getLineAt(0), QStringLiteral("普通歌词")); + + // 第二行:逐字歌词 + EXPECT_TRUE(la.hasWordTiming(1)); + EXPECT_EQ(la.getLineAt(1), QStringLiteral("逐字歌词")); + EXPECT_EQ(la.getWordTiming(1).size(), 4); + + // 第三行:普通歌词 + EXPECT_FALSE(la.hasWordTiming(2)); + EXPECT_EQ(la.getLineAt(2), QStringLiteral("另一行普通歌词")); +} + +TEST(LyricAnalysisTest, multiTimestampSortedByFirstTime) +{ + // 多行逐字歌词,按首时间排序 + TempLrcFile tempLrc( + "[00:10.00]第[00:10.20]二[00:10.40]行\n" + "[00:05.00]第[00:05.20]一[00:05.40]行\n"); + LyricAnalysis la; + la.setFromFile(tempLrc.path()); + ASSERT_EQ(la.getCount(), 2); + EXPECT_EQ(la.getLineAt(0), QStringLiteral("第一行")); + EXPECT_EQ(la.getLineAt(1), QStringLiteral("第二行")); + EXPECT_LE(la.getPostion(0), la.getPostion(1)); +} + +TEST(LyricAnalysisTest, wordTimingInvalidIndexReturnsEmpty) +{ + LyricAnalysis la; + EXPECT_TRUE(la.getWordTiming(-1).isEmpty()); + EXPECT_TRUE(la.getWordTiming(999).isEmpty()); + EXPECT_FALSE(la.hasWordTiming(-1)); + EXPECT_FALSE(la.hasWordTiming(999)); +}