Uh oh!
There was an error while loading. Please reload this page.
') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ', 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ', 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ', 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); })();
There was an error while loading. Please reload this page.
参赛项目名称
Lyra · 在听歌的过程中学习一门语言
团队 / 作者
晓风乾(个人参赛者)
我做了什么
市面上所有语言学习应用都在让你「学」,但绝大多数人打开它是靠毅力,不是靠喜欢。
我想做的是反过来:你本来就在听的那首歌,就是课本。
Lyra 是一个网页播放器。你像平时一样放一首日语歌,歌词跟着音乐逐字高亮;
听到不懂的地方,点一下那个词,它会告诉你这个词的读音、词性、词典义,
以及最关键的一层 —— 它在这一句歌词里到底什么意思。
觉得没听清,点「循环这句」,播放器就在这一句上反复循环,直到你听进去。
听过的每首歌会变成夜空里的一个星座,学过的词是星座上的星点,越听越亮。
具体用百炼做了三件事:
逐字注音与切分:把整句日语歌词切成词,判断哪些是实词、哪些是助词,
给汉字标假名读音和罗马音。日语的难点在于同一个汉字在不同词里读法完全不同
(「行く」读 iku、「行う」读 okonau),必须结合上下文判断。
语境释义:这是产品的核心价值,也是它和「点词弹词典」的分界线。
同样是「沈む」,词典义是「下沉、沉没」,但在「沈むように溶けてゆくように」这句里,
模型给出的是「形容事物如沉入水中般缓慢、不可逆地消失或消融」——
这是针对这一句生成的解释,不是词典搬运。
例句生成:用你听过的歌里出现过的词,生成新的例句,让同一个词在另一个语境里再出现一次。
工程上刻意守住的几条纪律(都有测试守门):
模型永远不写掌握度 —— 因为「你学会了没有」这件事不能靠模型的自信程度来定。
站上已有 70 多首歌、6700 多条词与语法的理解结果,7 月上线至今持续在线。
上线前后累计有 200+ 位用户参与盲测,其中三位不同画像的用户(日语零基础、N3、只认罗马音)
各深度使用了两天 —— 下面踩坑记录第 4 条就是他们挑出来的问题。
使用的工具
经百炼 OpenAI 兼容接口(https://dashscope.aliyuncs.com/compatible-mode/v1)接入,
使用 JSON 结构化输出模式保证返回可直接落库
SQLite 分库存储,MeCab 做日语形态分析给模型输出做接地校验
效果展示
在线体验:https://lyra.niuniu869.com (已开放注册,无需邀请码,建议戴耳机)
体验路径:注册 → 搜「夜に駆ける」→ 点播放 → 歌词逐字亮起 → 点任意一个词 → 看语境释义 → 点「循环这句」→ 看「地图」里的星图
项目链接(可选)
踩坑记录(可选)
1. 音源里混着「看起来正常」的残片,会静默毁掉体验。
有一首歌的音频缓存只有 81KB,但歌曲时长是 303 秒。用户点开能播,播到第 5 秒就静默卡死,
界面没有任何报错 —— 这比直接报错糟糕得多,用户会以为是自己网络的问题,傻等下去。
最初我判断是「下载中断」,加了
Content-Length校验,结果重新下载还是 81KB,而且完整通过了校验 —— 说明上游返回的就是个 5 秒试听片段,不是传输中断。
最终按「时长 × 最低码率」做完整性下限校验(303 秒的歌至少该有 1.2MB),
并把这类曲目标记后从推荐和搜索里隐藏,再加一个每天跑的复检任务,
等上游音源恢复了自动放回列表。
2. 歌词源会返回「简体转写版」的日语。
《残酷な天使のテーゼ》48 行歌词里有 10 行是简体字形:「少年よ神话になれ」(应为「神話」)、
「苍い风」(应为「蒼い風」)。对一个日语学习产品来说,这是在教用户写错字。
这里踩了两个坑:一是以为换个 CJK 字体能解决 —— 不能,「话 U+8BDD」和「話 U+8A71」是
两个不同的 Unicode 码位,不是同一个字的两种字形,字体只能改字形不能改码位;
二是想过交给模型去改 —— 但字形映射有唯一正确答案,查表 100% 准确、可单元测试、零延迟,
而模型有幻觉风险,更可能顺手改动字数或断句,那会直接打断逐字高亮赖以对齐的时间轴。
最后用确定性映射表解决,并且只对已判定为日语的歌词生效 ——
第一版我把它放在通用的歌词解析函数里,结果中文歌的歌词也被「日化」了(「真歌词」→「真歌詞」),
全量测试当场挂了 3 个才发现。
3. 定时任务可以「安静地坏」。
复检任务写完、单元测试全绿、部署上线。但我按 cron 的真实方式跑了一次,
日志立刻报
'NoneType' object has no attribute 'headers'—— 我传了个None当会话对象。如果没跑那一次,这个任务会每天准时执行、正常退出、日志看着一切正常,
而复检功能从第一天起就是死的,没有任何人会发现。
根本原因是单元测试把那个函数整个 mock 掉了,永远测不到真实调用契约。
单测绿不等于功能活。
4. 上线前找了三个不同画像的用户各用两天,问题几乎全是我自己想不到的。
一个日语零基础的上班族、一个 N3 水平的学生、一个只认罗马音的动漫迷。
三个人都在同一个地方卡住:注册后弹出的口味问卷里的歌手他们一个都不认识。
零基础用户的原话是:「而且没有 YOASOBI —— 我就是冲它来的。」
查下来发现歌手列表是按「缓存里出现最多」排的,选出来的是系统内部的高频项,
而不是用户认得的名字。N3 用户则一眼看出片假名被逐字注了平假名读音(
レ(れ)モ(も)ン(ん)),指出这是纯视觉噪音 —— 会读片假名的人本来就会读平假名。
这些都不是能靠自己盯着代码看出来的。