Initial public release of JustOneCacophony

This commit is contained in:
2026-09-01 16:31:38 +08:00
commit aa3a519f79
38 changed files with 9073 additions and 0 deletions
+282
View File
@@ -0,0 +1,282 @@
"""从常见 E-AC-3 同步帧直接提取连续 EMDF 容器。
扫描器检查八种全局位对齐,定位 ``0x5838`` 同步字,验证容器长度并解析各
payload config,因此不要求 EMDF 在原始 E-AC-3 文件中按字节对齐。
本模块有意只覆盖“完整 EMDF 容器在一个同步帧中连续出现”的常见情形。不解析
E-AC-3 mantissa,也不重组被音频数据隔开的多个 skip-field 碎片;遇到这种输入会
明确报错,让上层决定是否使用兼容桥。
"""
from dataclasses import dataclass
import csv
import hashlib
from pathlib import Path
import numpy as np
from variant_error import UnsupportedVariantError
SYNCWORD = 0x5838
REQUIRED_JOC_IDS = frozenset((11, 14))
class EmdfError(ValueError):
"""EMDF 或其 E-AC-3 传输结构不符合本实现支持的范围。"""
class BitReader:
"""MSB-first 位读取器;位置以源数据的绝对 bit offset 表示。"""
def __init__(self, data, position=0, limit=None):
self.data = memoryview(data)
self.position = int(position)
self.limit = len(self.data) * 8 if limit is None else int(limit)
def read(self, count):
count = int(count)
if count < 0 or self.position + count > self.limit:
raise EmdfError(f"位流越界 @bit{self.position}, need={count}, limit={self.limit}")
value = 0
while count:
byte_pos = self.position >> 3
removed_left = self.position & 7
take = min(count, 8 - removed_left)
shift = 8 - removed_left - take
value = (value << take) | ((self.data[byte_pos] >> shift) & ((1 << take) - 1))
self.position += take
count -= take
return value
def skip(self, count):
self.read(count)
def read_bytes(self, count):
return bytes(self.read(8) for _ in range(count))
def variable_bits(reader, width, max_groups=8):
"""读取 EMDF ``variable_bits(width)`` 变长整数。"""
value = 0
for _ in range(max_groups):
value += reader.read(width)
more = reader.read(1)
if not more:
return value
value = (value + 1) << width
raise EmdfError(f"variable_bits({width}) 延伸组过多")
@dataclass(frozen=True)
class EmdfContainer:
start_bit: int
raw: bytes
payloads: dict
sample_offsets: dict
def _parse_at(data, start_bit):
"""在已知 syncword 的 bit offset 解析一个 EMDF 容器。"""
reader = BitReader(data, start_bit)
if reader.read(16) != SYNCWORD:
raise EmdfError(f"EMDF syncword 不匹配 @bit{start_bit}")
length = reader.read(16)
body_start = reader.position
body_end = body_start + length * 8
if body_end > reader.limit:
raise EmdfError(f"EMDF 容器越界 @bit{start_bit}: length={length}")
reader.limit = body_end
version = reader.read(2)
if version == 3:
version += variable_bits(reader, 2)
key_id = reader.read(3)
if key_id == 7:
key_id += variable_bits(reader, 3)
# TS 103 420 JOC 使用 version=0/key_id=0;严格限制也能排除音频中的伪 marker。
if version != 0 or key_id != 0:
raise EmdfError(f"不支持的 EMDF version/key_id: {version}/{key_id}")
payloads = {}
sample_offsets = {}
terminated = False
while reader.position + 5 <= body_end:
payload_id = reader.read(5)
if payload_id == 0:
terminated = True
break
if payload_id == 0x1F:
payload_id += variable_bits(reader, 5)
if payload_id in payloads:
raise EmdfError(f"同一 EMDF 容器重复 payload id {payload_id}")
has_sample_offset = bool(reader.read(1))
sample_offset = (reader.read(12) >> 1) if has_sample_offset else 0
if reader.read(1):
variable_bits(reader, 11) # duration
if reader.read(1):
variable_bits(reader, 2) # group id
if reader.read(1):
reader.skip(8) # codec data
if not reader.read(1): # discard_unknown_payload
frame_aligned = False
if not has_sample_offset:
frame_aligned = bool(reader.read(1))
if frame_aligned:
reader.skip(2)
if has_sample_offset or frame_aligned:
reader.skip(7)
payload_size = variable_bits(reader, 8)
if reader.position + payload_size * 8 > body_end:
raise EmdfError(
f"payload id {payload_id} 越界: size={payload_size}, @bit{reader.position}")
payloads[payload_id] = reader.read_bytes(payload_size)
sample_offsets[payload_id] = sample_offset
if not terminated:
raise EmdfError("EMDF 容器缺少 payload id 0 终止符")
total_bytes = 4 + length
raw_reader = BitReader(data, start_bit, start_bit + total_bytes * 8)
raw = raw_reader.read_bytes(total_bytes)
return EmdfContainer(start_bit, raw, payloads, sample_offsets)
def _marker_offsets(data):
"""以 NumPy 批量检查八种位移,返回可能的 0x5838 bit offsets。"""
source = np.frombuffer(data, dtype=np.uint8)
if source.size < 4:
return []
offsets = []
for shift in range(8):
if shift == 0:
aligned = source
else:
aligned = np.bitwise_or(
np.left_shift(source[:-1].astype(np.uint16), shift) & 0xFF,
np.right_shift(source[1:].astype(np.uint16), 8 - shift),
).astype(np.uint8)
hits = np.flatnonzero((aligned[:-1] == 0x58) & (aligned[1:] == 0x38))
offsets.extend(int(hit) * 8 + shift for hit in hits)
return sorted(offsets)
def find_joc_emdf(frame):
"""返回同步帧中唯一、连续且包含 ID11/ID14 的 JOC EMDF 容器。"""
matches = []
offsets = _marker_offsets(frame)
parsed_candidates = []
parse_errors = []
for start_bit in offsets:
try:
container = _parse_at(frame, start_bit)
except EmdfError as exc:
if len(parse_errors) < 8:
parse_errors.append({"start_bit": start_bit, "error": str(exc)})
continue
parsed_candidates.append({
"start_bit": start_bit,
"payload_ids": list(container.payloads),
"payload_lengths": {str(k): len(v) for k, v in container.payloads.items()},
})
if REQUIRED_JOC_IDS.issubset(container.payloads):
matches.append(container)
if not matches:
raise UnsupportedVariantError(
"emdf_transport", "no_contiguous_joc_container",
"同步帧中未找到可连续解析且同时包含 ID11/ID14 的 EMDF 容器",
details={
"syncframe_bytes": len(frame),
"marker_bit_offsets": offsets,
"parsed_candidates": parsed_candidates,
"candidate_parse_errors": parse_errors,
"repair_hint": "检查 EMDF 是否跨多个 audio-block skip field 分片,或 payload config 是否变化",
})
if len(matches) != 1:
starts = [item.start_bit for item in matches]
raise UnsupportedVariantError(
"emdf_transport", "multiple_joc_containers",
"同步帧中存在多个可用 JOC EMDF,当前无法自动选择",
details={"syncframe_bytes": len(frame), "joc_container_start_bits": starts})
return matches[0]
def parse_container(data):
"""解析从 syncword 开始、已经重新按字节对齐保存的 EMDF 容器。"""
container = _parse_at(data, 0)
if len(container.raw) != len(data):
raise EmdfError(f"EMDF 文件尾有额外数据: parsed={len(container.raw)}, file={len(data)}")
return container
def iter_eac3_frames(data):
"""按 E-AC-3 ``frmsiz`` 遍历同步帧,拒绝静默重同步。"""
pos = 0
while pos < len(data):
if pos + 4 > len(data) or data[pos:pos + 2] != b"\x0b\x77":
raise UnsupportedVariantError(
"eac3_transport", "syncframe_header",
"E-AC-3 同步帧头无效或出现了未处理的子流排列",
details={
"byte_offset": pos,
"remaining_bytes": len(data) - pos,
"next_16_bytes_hex": data[pos:pos + 16].hex(),
})
size = ((((data[pos + 2] & 7) << 8) | data[pos + 3]) + 1) * 2
if pos + size > len(data):
raise UnsupportedVariantError(
"eac3_transport", "truncated_syncframe",
"E-AC-3 末帧长度超过输入剩余数据",
details={
"byte_offset": pos,
"declared_frame_bytes": size,
"remaining_bytes": len(data) - pos,
})
yield data[pos:pos + size]
pos += size
def extract_index(eac3_path, output_dir, max_frames=None):
"""将裸 E-AC-3 的连续 EMDF 保存为 ``frames.csv + emdf/``。"""
output_dir = Path(output_dir)
emdf_dir = output_dir / "emdf"
emdf_dir.mkdir(parents=True, exist_ok=True)
frames = iter_eac3_frames(Path(eac3_path).read_bytes())
rows = []
for frame_number, frame in enumerate(frames):
if max_frames is not None and frame_number >= max_frames:
break
try:
container = find_joc_emdf(frame)
except UnsupportedVariantError as exc:
exc.add_context(frame=frame_number, details={"syncframe_bytes": len(frame)})
raise
except EmdfError as exc:
raise UnsupportedVariantError(
"emdf_transport", "container_syntax",
"EMDF 容器语法无法解析",
frame=frame_number,
details={"syncframe_bytes": len(frame), "parser_error": str(exc)}) from exc
digest = hashlib.sha256(container.raw).hexdigest()
target = emdf_dir / f"{digest}.bin"
if not target.is_file():
target.write_bytes(container.raw)
rows.append({
"frame": frame_number,
"emdf_hash": digest,
"emdf_size": len(container.raw),
"emdf_start_bit": container.start_bit,
"payload_ids": ";".join(str(x) for x in container.payloads),
"error": "",
})
if (frame_number + 1) % 1000 == 0:
print(f"[metadata] {frame_number + 1} frames", flush=True)
if not rows:
raise EmdfError("E-AC-3 输入中没有可处理的同步帧")
with (output_dir / "frames.csv").open("w", encoding="utf-8", newline="") as fp:
fields = ("frame", "emdf_hash", "emdf_size", "emdf_start_bit", "payload_ids", "error")
writer = csv.DictWriter(fp, fieldnames=fields)
writer.writeheader()
writer.writerows(rows)
return output_dir