这是一个语音审核Python代码,集成了语音识别和本地敏感词匹配,能快速判断音频中是否存在违规内容。
```python
-*- coding: utf-8 -*-
"""
语音内容审核 Demo
流程:音频转码(ffmpeg) -> 语音识别(ASR) -> 文本审核(敏感词DFA + 正则) -> 风险定级
"""
from future import annotations
import asyncio
import json
import logging
import os
import re
import subprocess
import tempfile
import unicodedata
from dataclasses import dataclass, asdict
from enum import IntEnum
from pathlib import Path
from typing import Any, Iterable, Protocol, Sequence
logger = logging.getLogger(name)
---------------------------------------------------------------------------
一、基础数据结构
---------------------------------------------------------------------------
class RiskLevel(IntEnum):
PASS = 0 # 通过
REVIEW = 1 # 疑似,转人工复审
BLOCK = 2 # 违规,直接拦截
RISK_LABELS = {
RiskLevel.PASS: "通过",
RiskLevel.REVIEW: "疑似-转人工",
RiskLevel.BLOCK: "违规-拦截",
}
CATEGORY_LABELS = {
"porn": "色情低俗",
"politics": "涉政敏感",
"violence": "暴力恐怖",
"abuse": "辱骂攻击",
"ad": "广告导流",
"pii": "个人隐私",
}
分类 -> 默认风险等级
DEFAULT_CATEGORY_RISK = {
"porn": RiskLevel.BLOCK,
"politics": RiskLevel.BLOCK,
"violence": RiskLevel.BLOCK,
"abuse": RiskLevel.REVIEW,
"ad": RiskLevel.REVIEW,
"pii": RiskLevel.REVIEW,
}
@dataclass
class Segment:
"""ASR 输出的一个语音片段"""
start: float
end: float
text: str
@dataclass
class Hit:
"""一条命中记录"""
keyword: str # 命中的原文内容
category: str # 违规分类
source: str # lexicon / regex
segment_index: int # 所属语音片段下标
text_start: int # 在片段文本中的起始偏移
text_end: int
time_start: float # 对应音频时间(秒,估算)
time_end: float
snippet: str # 上下文,便于人工复核
@dataclass
class ModerationResult:
audio: str
duration: float
risk: RiskLevel
text: str
segments: listSegment
hits: listHit
@property
def passed(self) -> bool:
return self.risk == RiskLevel.PASS
def to_dict(self) -> dictstr, Any:
return {
"audio": self.audio,
"duration": round(self.duration, 3),
"risk": self.risk.name,
"risk_label": RISK_LABELSself.risk,
"passed": self.passed,
"text": self.text,
"hits": asdict(h) for h in self.hits,
"segments": asdict(s) for s in self.segments,
}
---------------------------------------------------------------------------
二、敏感词匹配(DFA / Trie,O(n) 扫描,支持最长匹配)
---------------------------------------------------------------------------
_END = "\x00" # 词尾标记,正常文本不会包含该字符
归一化时需要跳过的干扰字符(空格 / 标点等,防止 "赌 博"、"赌*博" 绕过)
_SKIP_CHARS = set(
" \t\r\n\u3000,,.。!!??;;::'\"""''()()\[\]【】<>《》----_+*/\\|~`·、"
)
def normalize(text: str) -> tuplestr, list\[int]:
"""
归一化文本:NFKC(全角转半角)+ 小写 + 去干扰字符。
返回 (归一化文本, 每个字符在原文中的下标),便于把命中位置映射回原文。
"""
chars: liststr = \[\]
mapping: listint = \[\]
for i, raw in enumerate(text):
for ch in unicodedata.normalize("NFKC", raw).lower():
if ch.isspace() or ch in _SKIP_CHARS:
continue
chars.append(ch)
mapping.append(i)
return "".join(chars), mapping
class LexiconMatcher:
"""基于 Trie 的敏感词匹配器,返回最长匹配结果"""
def init(self) -> None:
self._root: dictstr, Any = {}
def add(self, word: str, category: str) -> None:
word = word.strip()
if not word:
return
node = self._root
for ch in word:
node = node.setdefault(ch, {})
node_END = category
def build(self, items: Iterabletuple\[str, str]) -> None:
for word, category in items:
self.add(word, category)
def match(self, text: str) -> listtuple\[int, int, str, str]:
"""返回 (start, end, word, category), ...,非重叠、最长优先"""
results: listtuple\[int, int, str, str] = \[\]
n = len(text)
i = 0
while i < n:
node = self._root
j = i
last_hit: tupleint, int, str, str | None = None
while j < n and textj in node:
node = nodetext\[j]
j += 1
if _END in node:
last_hit = (i, j, texti:j, node_END)
if last_hit:
results.append(last_hit)
i = last_hit1
else:
i += 1
return results
---------------------------------------------------------------------------
三、文本审核:词库 + 正则规则
---------------------------------------------------------------------------
REGEX_RULES: listtuple\[str, str, re.Pattern] = [
("pii", "手机号", re.compile(r"(?<!\d)13-9\d{9}(?!\d)")),
("pii", "身份证号", re.compile(r"(?<!\d)\d{17}\\dXx(?!\d)")),
("ad", "引流联系方式",
re.compile(r"(?:加|联系|私聊|咨询)?\s*(?:微信|vx|v信|薇信|qq|企鹅)\s*::?\s*a-zA-Z0-9_\\-{4,}", re.I)),
("ad", "外部链接", re.compile(r"(?:https?://|www\.)\^\\s,。!?+", re.I)),
]
class TextModerator:
def init(
self,
lexicon: Iterabletuple\[str, str] = (),
category_risk: dictstr, RiskLevel | None = None,
) -> None:
self.matcher = LexiconMatcher()
self.matcher.build(lexicon)
self.category_risk = {**DEFAULT_CATEGORY_RISK, **(category_risk or {})}
-- 时间估算:按字符比例映射到片段起止时间 ----------------------------
@staticmethod
def _time_at(seg: Segment, text: str, idx: int) -> float:
if not text:
return seg.start
ratio = min(max(idx / len(text), 0.0), 1.0)
return seg.start + (seg.end - seg.start) * ratio
def moderate(self, segments: SequenceSegment) -> listHit:
hits: listHit = \[\]
for si, seg in enumerate(segments):
hits.extend(self._scan_segment(si, seg))
去重(同一位置同一分类只保留一条)
seen: settuple\[int, int, int, str] = set()
unique: listHit = \[\]
for h in hits:
key = (h.segment_index, h.text_start, h.text_end, h.category)
if key not in seen:
seen.add(key)
unique.append(h)
return unique
def _scan_segment(self, si: int, seg: Segment) -> listHit:
hits: listHit = \[\]
text = seg.text
1) 敏感词库
norm, mapping = normalize(text)
if norm:
for s, e, _word, cat in self.matcher.match(norm):
ts, te = mappings, mappinge - 1 + 1
hits.append(Hit(
keyword=textts:te,
category=cat,
source="lexicon",
segment_index=si,
text_start=ts,
text_end=te,
time_start=round(self._time_at(seg, text, ts), 3),
time_end=round(self._time_at(seg, text, te), 3),
snippet=textmax(0, ts - 10): te + 10,
))
2) 正则规则(手机号、链接等,直接在原文上匹配)
for cat, name, pattern in REGEX_RULES:
for m in pattern.finditer(text):
ts, te = m.start(), m.end()
hits.append(Hit(
keyword=m.group(),
category=cat,
source=f"regex:{name}",
segment_index=si,
text_start=ts,
text_end=te,
time_start=round(self._time_at(seg, text, ts), 3),
time_end=round(self._time_at(seg, text, te), 3),
snippet=textmax(0, ts - 10): te + 10,
))
return hits
def calc_risk(self, hits: SequenceHit) -> RiskLevel:
level = RiskLevel.PASS
for h in hits:
level = max(level, self.category_risk.get(h.category, RiskLevel.REVIEW))
return level
---------------------------------------------------------------------------
四、语音识别引擎
---------------------------------------------------------------------------
class ASREngine(Protocol):
def transcribe(self, wav_path: Path) -> listSegment: ...
class WhisperASR:
"""本地 ASR(faster-whisper)。首次运行会自动下载模型。"""
def init(
self,
model_size: str = "small",
device: str = "cpu",
compute_type: str = "int8",
language: str = "zh",
) -> None:
from faster_whisper import WhisperModel # 延迟导入,避免无依赖时导入失败
self.model = WhisperModel(model_size, device=device, compute_type=compute_type)
self.language = language
def transcribe(self, wav_path: Path) -> listSegment:
segments, _info = self.model.transcribe(
str(wav_path),
language=self.language,
beam_size=5,
vad_filter=True, # 静音过滤,减少幻觉
vad_parameters={"min_silence_duration_ms": 500},
)
return [
Segment(float(s.start), float(s.end), s.text.strip())
for s in segments
if s.text and s.text.strip()
]
class CloudASR:
"""
云厂商 ASR 适配器(占位)。
生产环境建议直接使用云厂商的「语音内容审核」一体化接口,例如:
-
腾讯云 音频审核 (AudioModeration)
-
阿里云 语音反垃圾 (VoiceModeration)
它们除了文本违规,还能识别音频本身的风险(暴恐、低俗音频、声纹黑名单等)。
"""
def init(self, client: Any, bucket: str, **kwargs: Any) -> None:
self.client = client
self.bucket = bucket
def transcribe(self, wav_path: Path) -> listSegment:
raise NotImplementedError("按所选云厂商 SDK 实现")
---------------------------------------------------------------------------
五、音频工具
---------------------------------------------------------------------------
def convert_to_wav16k(src: Path, dst_dir: Path | None = None) -> Path:
"""用 ffmpeg 转成 16kHz 单声道 wav(ASR 标准输入)"""
fd, tmp = tempfile.mkstemp(suffix=".wav", dir=dst_dir)
os.close(fd)
cmd = [
"ffmpeg", "-nostdin", "-y", "-i", str(src),
"-vn", "-ac", "1", "-ar", "16000", "-f", "wav", tmp,
]
proc = subprocess.run(cmd, capture_output=True)
if proc.returncode != 0:
os.unlink(tmp)
raise RuntimeError(f"ffmpeg 转码失败: {proc.stderr.decode(errors='ignore')-500:}")
return Path(tmp)
def probe_duration(path: Path) -> float:
try:
out = subprocess.run(
["ffprobe", "-v", "error", "-show_entries", "format=duration",
"-of", "default=nw=1:nk=1", str(path)],
capture_output=True, text=True, check=True,
)
return float(out.stdout.strip())
except Exception:
return 0.0
---------------------------------------------------------------------------
六、审核主流程
---------------------------------------------------------------------------
class AudioModerator:
def init(
self,
asr: ASREngine | None = None,
text_moderator: TextModerator | None = None,
) -> None:
self.asr = asr or WhisperASR()
self.text_moderator = text_moderator or TextModerator(DEFAULT_LEXICON)
def moderate_file(self, audio_path: str | Path) -> ModerationResult:
src = Path(audio_path)
if not src.exists():
raise FileNotFoundError(src)
wav = convert_to_wav16k(src)
try:
duration = probe_duration(wav)
segments = self.asr.transcribe(wav)
finally:
wav.unlink(missing_ok=True)
hits = self.text_moderator.moderate(segments)
risk = self.text_moderator.calc_risk(hits)
full_text = "".join(s.text for s in segments)
result = ModerationResult(
audio=str(src),
duration=duration,
risk=risk,
text=full_text,
segments=segments,
hits=hits,
)
"审核完成 file=%s risk=%s hits=%d",
src.name, RISK_LABELSrisk, len(hits),
)
return result
async def moderate_file_async(self, audio_path: str | Path) -> ModerationResult:
"""ASR 是 CPU/IO 阻塞的,放到线程池里跑,避免阻塞事件循环"""
loop = asyncio.get_running_loop()
return await loop.run_in_executor(None, self.moderate_file, audio_path)
---------------------------------------------------------------------------
七、示例词库
---------------------------------------------------------------------------
DEFAULT_LEXICON: listtuple\[str, str] = [
色情低俗
("裸聊", "porn"), ("约炮", "porn"), ("一夜情", "porn"), ("黄色网站", "porn"),
涉政敏感(此处仅为占位示例,实际请接入合规词库服务)
("敏感词A", "politics"), ("敏感词B", "politics"),
暴力恐怖
("炸弹制作", "violence"), ("枪支买卖", "violence"),
辱骂
("傻逼", "abuse"), ("滚你妈", "abuse"), ("去死吧", "abuse"),
广告导流
("加我微信", "ad"), ("扫码进群", "ad"), ("内部渠道", "ad"),
赌博
("赌博", "porn"), ("博彩", "porn"), ("六合彩", "porn"),
]
---------------------------------------------------------------------------
八、使用示例
---------------------------------------------------------------------------
if name == "main":
import sys
logging.basicConfig(
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
)
if len(sys.argv) < 2:
print("用法: python audio_moderation.py <音频文件>")
sys.exit(1)
moderator = AudioModerator()
同步调用
res = moderator.moderate_file(sys.argv1)
print(json.dumps(res.to_dict(), ensure_ascii=False, indent=2))
异步调用(批量场景)
async def main():
results = await asyncio.gather(*[
moderator.moderate_file_async(p) for p in files
])
asyncio.run(main())
```
审核流程与核心功能拆解
下面按处理顺序梳理一下它主要做了哪些事。
· 音频预处理:通过 ffmpeg 将任意格式音频转为 16kHz 单声道 WAV,方便后续识别。
· 语音识别:默认使用 faster-whisper 本地模型,输出带时间戳的语音片段。
· 文本审核:对识别文本做归一化后,用敏感词 DFA 匹配和正则规则(手机号、链接等)检测违规内容。
· 风险定级:根据命中分类映射到通过、疑似、拦截三个等级,并返回完整审核详情。
文章仅供参考用。