Python实现语音审核模型

这是一个语音审核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,

)

logger.info(

"审核完成 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(

level=logging.INFO,

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 匹配和正则规则(手机号、链接等)检测违规内容。

· 风险定级:根据命中分类映射到通过、疑似、拦截三个等级,并返回完整审核详情。

文章仅供参考用。

相关推荐
不是万能的豆学长1 小时前
filetidy 文件整理助手:一键归类 + 规范重命名(Python 零依赖单文件工具)
大数据·开发语言·python·技术美术
gCode Teacher 格码致知2 小时前
Pandas教学-18:number与numeric的区别
python·pandas
梦想不只是梦与想5 小时前
Python 中subprocess 模块:执行外部命令
python·subprocess·调用外部命令
懂点Tech5 小时前
ctfshow MISC入门图片篇(信息附加):misc41解题思路
开发语言·python
catchadmin6 小时前
PHP 8.7 协程新提案 让跨协程代码共享请求身份
开发语言·php
念何架构之路7 小时前
go-grpc设计思想
开发语言·后端·golang
酷炫码神7 小时前
Day 10|Pinia 状态管理:把散落在各组件的数据,收进一个全局仓库
android·开发语言·javascript
Elastic 中国社区官方博客8 小时前
我们如何通过 70 多个拉取请求,将 OpenTelemetry Java agent 迁移到 invokedynamic
java·大数据·开发语言·elasticsearch·搜索引擎·全文检索·es
tyn1888 小时前
记录本地安装nacos服务的方法及异常
java·开发语言
步行cgn9 小时前
Spring MVC 视图机制的核心接口与类
java·开发语言·后端·spring·mvc