Skip to content
Chσimσε edited this page May 29, 2025 · 1 revision

PySimaiParser

1. 引言

本文档旨在为使用 PySimaiParser 库的开发者提供技术参考。PySimaiParser 是一个用于解析 Simai 谱面文件并将其转换为结构化 JSON 格式的 Python 库,同时也支持将 JSON 数据转换回 Simai 文本格式。

本文档将详细介绍库中的核心数据结构、类、主要方法及其功能。

2. 核心组件

2.1. SimaiChart 类 (位于 core.py)

SimaiChart 是解析和存储 Simai 谱面数据的主要类。

2.1.1. 主要属性

  • metadata (dict): 存储谱面的元数据。
    • "title" (str): 歌曲标题。
    • "artist" (str): 艺术家名称。
    • "designer" (str): 谱面设计者。
    • "first_offset_sec" (float): 从 &first 命令获取的初始谱面偏移时间(秒)。
    • "levels" (list[str]): 包含7个元素的列表,对应 &lv_1&lv_7 的难度等级字符串。
    • "other_commands_raw" (str): 其他未被特别解析的元数据命令的原始文本。
  • fumens_raw (list[str]): 包含7个元素的列表,存储每个难度(&inote_1&inote_7)的原始谱面字符串。
  • processed_fumens_data (list[dict]): 存储解析和处理后的谱面数据。每个字典代表一个难度,包含音符事件和时间点事件。

2.1.2. 主要方法

  • __init__(self):
    • 初始化 metadatafumens_raw 属性。
  • load_from_text(self, simai_text_content: str):
    • 功能: 从包含 Simai 谱面内容的字符串中加载并解析数据。
    • 流程:
      1. 按行分割输入文本。
      2. 遍历每一行,根据行首的标记(如 &title=&inote_)区分元数据和谱面数据。
      3. 解析元数据并存入 self.metadata
      4. 提取各个难度的原始谱面字符串(&inote_X 块内的内容)并存入 self.fumens_raw
      5. 调用 _process_all_fumens() 方法处理原始谱面数据。
  • _process_all_fumens(self):
    • 功能: 处理所有已加载的原始谱面字符串,将其转换为结构化的音符和时间点数据。
    • 流程:
      1. 遍历 self.fumens_raw 中的每个原始谱面文本。
      2. 如果谱面文本非空,则调用 _parse_single_fumen() 进行解析。
      3. 将解析结果(音符事件列表和逗号时间点事件列表)存入 self.processed_fumens_data 中,每个元素对应一个难度。
  • _parse_single_fumen(self, fumen_text: str) -> tuple[list[SimaiTimingPoint], list[SimaiTimingPoint]]:
    • 功能: 解析单个难度谱面(&inote_ 块内)的字符串。
    • 参数:
      • fumen_text (str): 单个难度的谱面字符串。
    • 返回:
      • note_events_list (list[SimaiTimingPoint]): 包含实际音符的 SimaiTimingPoint 对象列表。
      • timing_events_at_commas_list (list[SimaiTimingPoint]): 代表每个逗号(谱面分隔符)的 SimaiTimingPoint 对象列表。
    • 核心逻辑:
      1. 初始化当前 BPM、节拍数、时间(秒)、变速等状态变量。
      2. 逐字符遍历谱面文本。
      3. 处理注释: || ... \n,跳过注释内容。
      4. 处理 BPM 变化: (value),更新当前 BPM。
      5. 处理节拍记号变化: {value},更新每小节的节拍数。
      6. 处理变速变化: <Hvalue><HS*value>,更新当前变速值。
      7. 处理换行符: \n,更新行号。
      8. 处理逗号分隔符: ,
        • 调用 _finalize_note_segment() 处理逗号前的音符内容。
        • 创建一个 SimaiTimingPoint 对象代表此逗号,并添加到 timing_events_at_commas_list
        • 根据当前 BPM 和节拍数,计算并增加 current_time_sec
      9. 累积音符字符: 其他字符被视为音符定义的一部分,累积到 note_buffer
      10. 谱面结束时,再次调用 _finalize_note_segment() 处理剩余的 note_buffer
      11. note_events_listtiming_events_at_commas_list 按时间排序。
  • _finalize_note_segment(self, note_buffer_str: str, time_sec: float, x_pos: int, y_pos: int, bpm: float, hspeed: float, note_events_list_ref: list):
    • 功能: 处理收集到的音符片段字符串(通常是两个逗号之间的内容)。
    • 核心逻辑:
      1. 去除音符字符串两端的空白。
      2. 如果包含 ``` (反引号,用于伪同时音符/装饰音):
        • 按 ``` 分割字符串。
        • 为每个部分创建一个 SimaiTimingPoint,时间上略作偏移(基于BPM的极小间隔)。
        • 调用 parse_notes_from_content() 解析每个部分的音符。
      3. 如果不包含 ```:
        • 创建一个 SimaiTimingPoint 对象。
        • 调用 parse_notes_from_content() 解析音符。
      4. 如果解析出了实际音符,则将 SimaiTimingPoint 对象添加到 note_events_list_ref
  • to_json(self, indent: int = 2) -> str:
    • 功能: 将整个解析后的 SimaiChart 对象(包括元数据和处理后的谱面数据)转换为 JSON 字符串。
    • 参数:
      • indent (int): JSON 输出的缩进级别。
    • 返回: JSON 格式的字符串。

2.2. SimaiTimingPoint 类 (位于 timing.py)

SimaiTimingPoint 代表谱面中的一个特定时间点,通常由 Simai 格式中的逗号标记。它包含在该时间点发生的音符以及当时的有效 BPM 和变速(HSpeed)信息。

2.2.1. 主要属性

  • time (float): 从歌曲开始计算的绝对时间(秒)。
  • raw_text_pos_x (int): 在原始谱面文本中的 X 字符位置(列号)。
  • raw_text_pos_y (int): 在原始谱面文本中的 Y 行位置(行号)。
  • notes_content_raw (str): 在此时间点的原始音符字符串 (例如, "1/2h[4:1]").
  • current_bpm (float): 此时间点有效的 BPM。
  • hspeed (float): 此时间点有效的变速倍率。
  • notes (list[SimaiNote]): 从 notes_content_raw 解析出的 SimaiNote 对象列表。

2.2.2. 主要方法

  • __init__(self, time, raw_text_pos_x=0, raw_text_pos_y=0, notes_content="", bpm=0.0, hspeed=1.0):
    • 初始化 SimaiTimingPoint 对象的各个属性。
  • parse_notes_from_content(self):
    • 功能: 解析 self.notes_content_raw 原始音符字符串,并填充 self.notes 列表。
    • 核心逻辑:
      1. 处理特殊情况,如两个数字直接相连("12" 解析为两个独立的 TAP)。
      2. 处理 / 分隔的同时音符 (例如, "1/2/E1"):递归调用 _parse_single_note_token_parse_same_head_slide
      3. 处理 * 定义的同头滑键 (例如, "1*V[4:1]"):调用 _parse_same_head_slide
      4. 对于单个音符标记,调用 _parse_single_note_token
  • _parse_same_head_slide(self, content_token: str) -> list[SimaiNote]:
    • 功能: 解析同头滑键组 (例如, "1*V[4:1]*<[4:1]")。第一个部分定义头部,后续部分是从同一头部开始的无头滑键。
    • 返回: SimaiNote 对象列表。
  • _parse_single_note_token(self, note_text_orig: str) -> SimaiNote:
    • 功能: 解析单个音符标记字符串 (例如, "1b", "A2h[4:1]", "3-4[8:1]bx") 为一个 SimaiNote 对象。这是个复杂的方法,因为它需要处理多种可能的修饰符和语法。
    • 核心步骤:
      1. 识别基础音符类型和位置:
        • 判断是 TAP (数字开头 1-8) 还是 TOUCH (字母开头 A-E, C)。
        • 设置 note.note_typenote.start_position / note.touch_area
      2. 解析修饰符并细化音符类型:
        • Hanabi (f): 设置 is_hanabi
        • Hold (h):
          • 如果音符类型是 TOUCH,则变为 TOUCH_HOLD。
          • 如果音符类型是 TAP,则变为 HOLD。
          • 调用 _get_time_from_beats_duration() 计算 hold_time
        • Slide (路径字符 -^v<>Vpqszw):
          • 音符类型变为 SLIDE。
          • 调用 _get_time_from_beats_duration() 计算 slide_time
          • 调用 _get_star_wait_time() 计算 slide_start_time_offset (滑键星出现前的等待时间)。
          • 处理 !? (无头滑键)。
        • Break (b):
          • 根据 b 出现的位置和上下文(是否在 SLIDE 中,是否后跟 [),设置 is_breakis_slide_break
        • EX (x): 设置 is_ex
        • Star ($, $$): 设置 is_force_staris_fake_rotate
    • 返回: 一个配置好的 SimaiNote 对象。
  • _get_time_from_beats_duration(self, note_text_token: str) -> float:
    • 功能: 从 Simai 的节拍表示法(如 [4:1][bpm#N:D][#绝对秒数])中解析持续时间,用于 HOLD 和 SLIDE。
    • 返回: 持续时间(秒)。
    • 支持的格式:
      • [N:D]: 在当前 BPM 下,N 分音符持续 D 个。时间 = (60.0 / current_bpm) * (4.0 / N) * D
      • [custom_bpm#N:D]: 在指定的 custom_bpm 下计算。
      • [#abs_time]: 直接指定绝对秒数。
      • [wait_time##duration_val]: duration_val 是持续秒数。
      • [custom_bpm#abs_time_val_for_this_bpm]: 在指定 custom_bpm 下的绝对秒数。
  • _get_star_wait_time(self, note_text_token: str) -> float:
    • 功能: 解析 SLIDE 音符的星星(star)视觉效果的等待时间。通常来自 [等待BPM#N:D][#绝对等待秒数##...] 这样的标记。默认等待时间是当前谱面 BPM 下的一个节拍。
    • 返回: 等待时间(秒)。
    • 支持的格式:
      • 默认:(60.0 / current_bpm)
      • [abs_wait_time##...]: abs_wait_time 是等待秒数。
      • [wait_bpm_override#...]: 使用 wait_bpm_override 计算一个节拍的等待时间。
  • to_dict(self) -> dict:
    • 功能: 将 SimaiTimingPoint 对象转换为字典,方便 JSON 序列化。

2.3. SimaiNote 类 (位于 note.py)

SimaiNote 代表 Simai 谱面中的单个音符或操作。它包含类型、位置、持续时间以及各种游戏性修饰符等属性。

2.3.1. SimaiNoteType (Enum)

定义了 Simai 音符的几种基本类型:

  • TAP
  • SLIDE
  • HOLD
  • TOUCH (外圈感应区音符)
  • TOUCH_HOLD (外圈感应区长按音符)

2.3.2. 主要属性

  • note_type (SimaiNoteType): 音符类型。
  • start_position (int | None): 起始位置。对于普通音符是 1-8。对于 TOUCH 音符,如果是 A1-E8 中的数字部分,或者对于 C 区固定为 8 (根据 C# 实现的约定)。
  • touch_area (str | None): TOUCH 音符的感应区域 (A-E, C)。
  • hold_time (float): HOLD 音符的持续时间(秒)。
  • slide_time (float): SLIDE 音符的持续时间(秒)。
  • slide_start_time_offset (float): SLIDE 音符的星标(star)出现时间相对于音符时间点的时间偏移(秒)。
  • is_break (bool): 是否为 BREAK 音符 (通常得分较高或有特殊音效)。
  • is_ex (bool): 是否为 EX 音符 (通常有更强的视觉/声音效果)。
  • is_hanabi (bool): 是否为烟花效果 (f 标记)。
  • is_slide_no_head (bool): SLIDE 是否无头 (!? 标记)。
  • is_force_star (bool): 是否强制 SLIDE 显示星标 ($ 标记)。
  • is_fake_rotate (bool): 是否为伪旋转效果 ($$ 标记)。
  • is_slide_break (bool): SLIDE 音符的滑键段本身是否为 BREAK (b 标记在滑键路径上,如 1-b[4:1])。
  • raw_note_text (str): 该音符的原始文本,用于调试或参考。

2.3.3. 主要方法

  • __init__(self):
    • 初始化所有属性为其默认值。
  • to_dict(self) -> dict:
    • 功能: 将 SimaiNote 对象转换为字典,方便 JSON 序列化。

2.4. JsonSimaiConverter 类 (位于 rebuild.py)

JsonSimaiConverter 用于将之前由 PySimaiParser 生成的 JSON 格式的谱面数据转换回 Simai 文本格式。

2.4.1. 主要属性

  • chart_data (dict): 包含谱面数据的字典,期望有 metadatafumens_data 键。
  • metadata (dict): 从 chart_data 中提取的元数据。
  • fumens_data (list[dict]): 从 chart_data 中提取的谱面数据。
  • standard_x_values (list[float]): 标准的 X 值列表(用于 {X} 节拍标记,代表一个全音符中可以容纳多少个当前类型的音符)。

2.4.2. 主要方法

  • __init__(self, chart_data_dict: dict):
    • 初始化转换器,加载输入的谱面数据字典。
  • from_json_file(cls, filepath: str, encoding: str = 'utf-8') -> 'JsonSimaiConverter':
    • 类方法: 从 JSON 文件加载数据并创建一个 JsonSimaiConverter 实例。
  • from_json_text(cls, json_text: str) -> 'JsonSimaiConverter':
    • 类方法: 从 JSON 字符串加载数据并创建一个 JsonSimaiConverter 实例。
  • to_simai_text(self) -> str:
    • 功能: 将加载的谱面数据转换回 Simai 文本格式。
    • 核心逻辑:
      1. 输出元数据:
        • 写入 &title, &artist, &des
        • 调用 _determine_chart_global_bpm() 确定并写入 &wholebpm
        • 写入 &first&lv_X
      2. 处理每个谱面 (fumen):
        • 遍历 self.fumens_data 中的每个谱面。
        • 写入谱面头 &inote_X=.
        • 事件排序: 合并 note_eventstiming_events_at_commas 并按时间排序。
        • 预计算 X 值: 遍历所有逗号事件,使用 _calculate_x_for_segment() 计算每个由逗号分隔的片段的 X 值,并存储在 comma_times_and_x 字典中。
        • 初始化谱面状态: 设置初始 BPM 和 HSpeed,并输出相应的 (BPM)<HSpeed> 命令。
        • 主事件循环: 遍历排序后的所有事件点 (音符或逗号)。
          • 处理 BPM/HSpeed 变化: 如果当前事件点的 BPM/HSpeed 与活动的不同,则先调用 flush_current_line() 输出当前行,然后输出新的 (BPM)<HSpeed> 命令。
          • 处理音符事件: 将音符的 notes_content_raw 添加到 notes_since_last_boundary 缓冲区。
          • 处理逗号事件:
            • notes_since_last_boundary 和逗号本身组合成一个小片段字符串。
            • comma_times_and_x 获取此片段的 X 值。
            • 根据 X 值决定当前 Simai 行最多能容纳多少个小片段 (max_segments_this_line)。
            • 如果当前行是空的,或者当前片段的 X 值与行主导 X 值接近且未超出行容量,则将小片段加入当前行 (current_line_output_segments)。
            • 否则,调用 flush_current_line() 输出已满的行,并开始新行。
        • 行刷新 (flush_current_line):
          • 如果当前行的主导 X 值 (x_governing_current_line) 不是标准 X 值,则尝试使用 _find_closest_standard_x() 找到最接近的标准 X 值。
          • 如果选择了不同的标准 X 值,为了保持片段的实际时间长度不变,需要调整 BPM:new_BPM = active_BPM * (original_X / target_X)。输出调整后的 (BPM) 命令。
          • 输出 {X} 标记(使用标准化的 X 值)。
          • 输出当前行累积的所有小片段。
        • 末尾处理: 循环结束后,再次调用 flush_current_line() 和输出剩余的 notes_since_last_boundary (通常是 E 标记)。
      3. 清理输出: 移除多余的空行,确保文件末尾有单个换行符。
    • 返回: Simai 格式的字符串。
  • _determine_chart_global_bpm(self) -> float | None:
    • 功能: 决定谱面的全局 BPM,用于写入 &wholebpm 元数据。
    • 逻辑: 检查每个谱面(fumen)的初始 BPM。如果一致,则使用该 BPM。否则,如果某个 BPM 出现频率显著较高,则使用它。最后回退到元数据中的 wholebpm (如果存在) 或找到的第一个 BPM。
  • _calculate_x_for_segment(self, segment_duration: float, bpm_at_segment_start: float) -> float:
    • 功能: 根据片段持续时间和片段开始时的 BPM 计算 X 值。
    • 公式: X = 240.0 / (segment_duration * bpm_at_segment_start)
    • 如果持续时间或 BPM 无效,则默认为 4.0。
  • _find_closest_standard_x(self, x_val: float) -> float | None:
    • 功能: 给定一个计算出的 X 值,找到 self.standard_x_values 中最接近的标准 X 值。
    • 逻辑:
      1. 如果 x_val 与某个标准值非常接近(容差内),则返回该标准值。
      2. 尝试将 x_val 转换为一个分母较小的简单分数,如果转换后的值与原值接近且是一个标准值或一个“良好”的整数,则使用它。
      3. 否则,返回算术上最接近的标准值。

3. JSON 输出数据结构

当调用 SimaiChart.to_json() 方法时,会生成一个 JSON 对象,其主要结构如下:

{
  "metadata": {
    "title": "歌曲标题",
    "artist": "艺术家",
    "designer": "谱师",
    "first_offset_sec": 1.25,
    "levels": ["", "", "", "13+", "", "", ""], // &lv_1 到 &lv_7
    "other_commands_raw": "任何其他未解析的元数据行"
  },
  "fumens_data": [
    // 每个元素代表一个难度,从索引 0 (对应 &inote_1) 开始
    { // 示例:索引 0,对应 &inote_1
      "difficulty_index": 0,
      "level_info": "", // 来自 metadata.levels[0]
      "note_events": [], // SimaiTimingPoint 对象的列表 (转换为字典)
      "timing_events_at_commas": [] // SimaiTimingPoint 对象的列表 (转换为字典)
    },
    // ... 其他难度 ...
    { // 示例:索引 3,对应 &inote_4
      "difficulty_index": 3,
      "level_info": "13+", // 来自 metadata.levels[3]
      "note_events": [
        // SimaiTimingPoint 对象 (转换为字典)
        {
          "time": 1.75, // 绝对时间 (秒)
          "raw_text_pos_x": 0,
          "raw_text_pos_y": 2, // 假设在谱面文本的第3行 (0-indexed)
          "notes_content_raw": "1",
          "current_bpm_at_event": 120.0,
          "hspeed_at_event": 1.0,
          "notes": [
            // SimaiNote 对象 (转换为字典)
            {
              "note_type": "TAP",
              "start_position": 1,
              "touch_area": null,
              "hold_time": 0.0,
              "slide_time": 0.0,
              "slide_start_time_offset": 0.0,
              "is_break": false,
              "is_ex": false,
              "is_hanabi": false,
              "is_slide_no_head": false,
              "is_force_star": false,
              "is_fake_rotate": false,
              "is_slide_break": false,
              "raw_note_text": "1"
            }
          ]
        },
        // ... 更多 note_events ...
      ],
      "timing_events_at_commas": [
        // SimaiTimingPoint 对象 (转换为字典), notes_content_raw 为 ""
        {
          "time": 1.75, // 逗号对应的时间点
          "raw_text_pos_x": 1, // 逗号在 "1," 中的位置
          "raw_text_pos_y": 2,
          "notes_content_raw": "", // 对于纯粹的逗号时间点,这里为空
          "current_bpm_at_event": 120.0,
          "hspeed_at_event": 1.0,
          "notes": [] // 通常为空,除非逗号后紧跟 E 标记且被解析为 note
        },
        // ... 更多 timing_events_at_commas ...
      ]
    }
    // ... 直到索引 6 (对应 &inote_7) ...
  ]
}

3.1. metadata 对象

键值对如 SimaiChart.metadata 属性所述。

3.2. fumens_data 数组

这是一个列表,每个元素是一个字典,代表一个难度的谱面数据。列表的索引对应谱面难度(0 对应 &inote_1,1 对应 &inote_2,以此类推)。

每个难度字典包含以下键:

  • "difficulty_index" (int): 当前难度的索引 (0-6)。
  • "level_info" (str): 该难度的等级信息,来自 metadata["levels"][difficulty_index]
  • "note_events" (list[dict]): 一个列表,其中每个元素是将 SimaiTimingPoint 对象(包含实际音符)调用 to_dict() 后得到的字典。这些事件点代表了谱面中实际需要玩家操作的音符。
  • "timing_events_at_commas" (list[dict]): 一个列表,其中每个元素是将代表谱面中每个逗号分隔符的 SimaiTimingPoint 对象调用 to_dict() 后得到的字典。这些主要用于标记时间流逝和节拍结构,其 notes_content_raw 通常为空,notes 列表也为空。

SimaiTimingPointSimaiNote 字典的结构分别对应其类中 to_dict() 方法的输出。

4. 命令行工具 (cli.py)

cli.py 提供了一个命令行界面,用于直接使用 PySimaiParser 的核心功能将 Simai 文本文件转换为 JSON。

  • 主要功能:

    • 接收输入 Simai 文件路径。
    • 可选输出 JSON 文件路径。
    • 可选 JSON 缩进级别。
  • 使用:

    python cli.py <input_file.txt> [-o <output_file.json>] [-i <indent_level>]
    
  • 内部实现:

    1. 解析命令行参数。
    2. 读取输入文件内容。
    3. 创建 SimaiChart 实例。
    4. 调用 chart.load_from_text() 解析谱面内容。
    5. 调用 chart.to_json() 生成 JSON 字符串。
    6. 将 JSON 输出到指定文件或标准输出。

该工具依赖于 SimaiParser 包中的 SimaiChart 类。