简介:本文从一段带罗马音标注的日文歌词出发,系统讲解如何用Python构建文本解析流水线,将非结构化歌词转为结构化数据。读者将掌握正则分词、音节映射、JSON序列化等核心技巧,并可将其迁移至多语言文本处理场景。
在自然语言处理和多语言内容管理领域,将带有注音标注的文本转化为结构化数据是一项常见但容易被低估的工程任务。以日文歌词为例,一段典型的文本通常包含以下混杂信息:
这些信息以高度非结构化的方式排列在一起,若需要进一步用于检索、对齐、字幕生成或语音合成,就必须先将其解析为结构化格式。本文将以一段实际的日文歌词文本为样本,完整讲解如何设计并实现一套解析流水线。
观察样本数据,可以归纳出以下模式:
見下(みお)ろした街 (まち) は透明 (とうめい) な海 (うみ)mi o ro shi ta ma chi wa to'u me'i na u mi
每一组歌词呈现为两行结构:
此外,文本还包含元信息行(如“Music:かしこ”“Singer:初音ミク”)以及对话/独白行(以「」包裹)。
我们希望将每一组歌词解析为如下结构:
{"line_index": 0,"original": "見下ろした街は透明な海","reading": "みおろしたまちはとうめいなうみ","romaji": "mi o ro shi ta ma chi wa to'u me'i na u mi","tokens": [{"surface": "見下", "reading": "みお", "romaji": "mi o"},{"surface": "ろした", "reading": "ろした", "romaji": "ro shi ta"}]}
这种结构化表示可以服务于多种下游任务:歌词对齐、注音渲染、罗马音检索、音节级时间戳对齐等。
整个解析流程可以分为四个阶段:
原始文本│▼[阶段一] 行分类与预处理│▼[阶段二] 日文注音解析│▼[阶段三] 罗马音音节对齐│▼[阶段四] 结构化输出与序列化
首先需要将原始文本按行拆分,并识别每一行的类型。常见的行类型包括:
import refrom enum import Enumclass LineType(Enum):META = "meta"JAPANESE = "japanese"ROMAJI = "romaji"DIALOGUE = "dialogue"UNKNOWN = "unknown"def classify_line(line: str) -> LineType:stripped = line.strip()if not stripped:return LineType.UNKNOWNif re.match(r'^(Music|Singer|Lyrics|Arrangement)\s*:', stripped):return LineType.METAif stripped.startswith('「') or stripped.startswith('『'):return LineType.DIALOGUE# 含CJK字符则判定为日文行if re.search(r'[\u3040-\u309F\u30A0-\u30FF\u4E00-\u9FFF]', stripped):return LineType.JAPANESE# 仅含ASCII字母、空格、撇号if re.match(r"^[a-zA-Z\s'&;#0-9]+$", stripped):return LineType.ROMAJIreturn LineType.UNKNOWN
预处理阶段还需要处理HTML实体转义问题。样本中出现了 & #39; 这样的实体编码,应当统一还原为撇号 ':
def normalize_entities(text: str) -> str:replacements = {'& #39;': "'",''': "'",'&': '&','<': '<','>': '>',}for entity, char in replacements.items():text = text.replace(entity, char)return text
日文歌词行的核心挑战在于:汉字后可能紧跟括号标注的读音,也可能没有。我们需要一个状态机或正则驱动的解析器来提取这些注音对。
典型的模式为:
漢字(よみ)残り
其中括号内为平假名读音,括号后可能还有其他字符。
# 匹配 汉字/假名 + 括号内平假名读音ANNOTATION_PATTERN = re.compile(r'([\u4E00-\u9FFF\u3005]+)' # 捕获组1:汉字(含重复符号々)r'[((]' # 左括号(全角或半角)r'([\u3040-\u309F\u30A0-\u30FF]+)' # 捕获组2:读音(平假名/片假名)r'[))]' # 右括号)def parse_japanese_line(line: str):"""解析日文歌词行,提取原文、读音和注音对。"""annotations = []reading_parts = []last_end = 0for match in ANNOTATION_PATTERN.finditer(line):# 括号前的非标注文本直接加入读音between = line[last_end:match.start()]if between:reading_parts.append(between)kanji = match.group(1)reading = match.group(2)annotations.append({"surface": kanji,"reading": reading,})reading_parts.append(reading)last_end = match.end()# 处理最后一个标注之后的剩余文本tail = line[last_end:]if tail:reading_parts.append(tail)# 还原原文(去除括号标注)original = ANNOTATION_PATTERN.sub(r'\1', line)reading = ''.join(reading_parts)return {"original": original.strip(),"reading": reading.strip(),"annotations": annotations,}
这段代码的关键在于:
last_end 记录上一次匹配结束位置,确保非标注部分不丢失sub 方法将 漢字(よみ) 替换为 漢字,得到纯净原文罗马音行以空格分隔音节,但日文原文中一个汉字可能对应多个音节。例如“見下”对应“mi o”,“透明”对应“to’u me’i”。要实现精确对齐,需要建立读音(假名)到罗马音音节的映射关系。
在实践中,可以采用以下策略:
策略一:基于标注数量的比例对齐
如果日文行有N个标注对,罗马音行有M个音节,且N ≈ M,则可以尝试逐对对齐。
策略二:基于假名数量的一对一映射
将假名读音逐字展开,再与罗马音音节按顺序对齐。这需要一个假名→罗马音的映射表:
KANA_TO_ROMAJI = {'あ': 'a', 'い': 'i', 'う': 'u', 'え': 'e', 'お': 'o','か': 'ka', 'き': 'ki', 'く': 'ku', 'け': 'ke', 'こ': 'ko','み': 'mi', 'お': 'o', 'ろ': 'ro', 'し': 'shi', 'た': 'ta','ま': 'ma', 'ち': 'chi', 'は': 'wa', 'と': 'to', 'う': 'u','め': 'me', 'な': 'na', 'う': 'u', 'み': 'mi',# ... 完整映射表需覆盖所有假名及拗音组合}
对于拗音(如“きょ”“しゃ”)和促音(“っ”),需要特殊处理:
def kana_to_romaji_sequence(kana: str) -> list:"""将假名序列转换为罗马音音节列表。"""result = []i = 0while i < len(kana):# 处理拗音:小写ゃゅょif i + 1 < len(kana) and kana[i+1] in 'ゃゅょ':combo = kana[i:i+2]result.append(YOUON_MAP.get(combo, combo))i += 2# 处理促音:っelif kana[i] == 'っ':# 促音使下一个音节的辅音重复result.append('Q') # 占位符i += 1# 处理拨音:んelif kana[i] == 'ん':result.append('n')i += 1else:result.append(KANA_TO_ROMAJI.get(kana[i], kana[i]))i += 1return result
将各阶段结果组装为统一的JSON结构,并支持多种输出格式:
import jsondef build_structured_output(parsed_lines: list) -> dict:"""将解析后的行组装为最终结构化文档。"""document = {"metadata": {},"lines": [],}for item in parsed_lines:if item["type"] == LineType.META:key, _, value = item["content"].partition(':')document["metadata"][key.strip()] = value.strip()elif item["type"] == LineType.JAPANESE:document["lines"].append({"index": len(document["lines"]),"original": item["parsed"]["original"],"reading": item["parsed"]["reading"],"romaji": item.get("romaji", ""),"annotations": item["parsed"]["annotations"],})return documentdef serialize(document: dict, fmt: str = "json") -> str:if fmt == "json":return json.dumps(document, ensure_ascii=False, indent=2)elif fmt == "yaml":# 可接入通用YAML序列化逻辑...raise ValueError(f"Unsupported format: {fmt}")
日文文本涉及多种Unicode区块:平假名(U+3040–U+309F)、片假名(U+30A0–U+30FF)、CJK统一汉字(U+4E00–U+9FFF)、全角标点等。在整个流水线中必须统一使用UTF-8编码,并在读写文件时显式指定:
with open('lyrics.txt', 'r', encoding='utf-8') as f:raw_text = f.read()
实际文本中总会出现无法归类的行。健壮的做法是为这些行分配一个“UNKNOWN”类型并保留原始内容,而非直接丢弃:
def safe_parse(lines: list) -> list:results = []for idx, line in enumerate(lines):try:line_type = classify_line(line)results.append({"index": idx, "type": line_type, "content": line})except Exception as e:results.append({"index": idx,"type": LineType.UNKNOWN,"content": line,"error": str(e),})return results
若需要支持韩文注音、中文拼音标注等场景,可以将解析器抽象为接口:
from abc import ABC, abstractmethodclass LyricsParser(ABC):@abstractmethoddef classify_line(self, line: str) -> LineType:...@abstractmethoddef parse_annotated_line(self, line: str) -> dict:...class JapaneseLyricsParser(LyricsParser):...
这样,新增一种语言只需实现对应的解析器子类,流水线的其余部分保持不变。
将歌词文本结构化的价值体现在多个方向:
| 应用场景 | 所需字段 | 技术要点 |
|---|---|---|
| 歌词注音渲染 | original + annotations | 按标注位置插入ruby标签 |
| 罗马音检索 | romaji | 建立倒排索引 |
| 音节级对齐 | reading + romaji | 动态规划对齐算法 |
| 语音合成输入 | reading | 假名序列直接送入TTS引擎 |
| 多语言字幕 | original + romaji | 时间轴对齐 |
以歌词注音渲染为例,利用解析出的annotations可以直接生成HTML的<ruby>标签:
def render_ruby(original: str, annotations: list) -> str:result = originalfor ann in reversed(annotations):surface = ann["surface"]reading = ann["reading"]ruby = f"<ruby>{surface}<rt>{reading}</rt></ruby>"result = result.replace(surface, ruby, 1)return result
本文从一段带有罗马音标注的日文歌词出发,系统性地介绍了将非结构化多语言文本转化为结构化数据的完整技术方案。核心步骤包括:
这套方法不仅适用于日文歌词,也可迁移至任何“原文+注音+转写”三行并存的多语言文本处理场景。工程实现中需特别注意字符编码、异常行容错和可扩展性设计,以确保流水线在真实数据上的鲁棒性。