File size: 7,404 Bytes
33a89be
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
# -*- coding: utf-8 -*-
"""
音源质量评分模块

提供多维度的音频质量评估,用于筛选最佳样本
支持时长、音量稳定性、音高稳定性三个评估维度
"""

import logging
import numpy as np
from typing import Dict, List, Optional, Tuple

logger = logging.getLogger(__name__)


def duration_score(duration: float) -> float:
    """
    时长评分:适中时长得分最高
    
    参数:
        duration: 音频时长(秒)
    
    返回:
        0~1 的分数
    
    评分逻辑:
        - 过短(<0.2s):发音不完整,低分
        - 最佳范围(0.3~0.8s):满分
        - 过长(>1.0s):可能包含多字或拖音,递减
    """
    if duration < 0.2:
        return duration / 0.2 * 0.5  # 0~0.5分
    elif duration <= 0.8:
        return 1.0  # 满分
    elif duration <= 1.2:
        return 1.0 - (duration - 0.8) / 0.4 * 0.3  # 0.7~1.0分
    else:
        return max(0.3, 0.7 - (duration - 1.2) * 0.2)  # 递减,最低0.3


def rms_variance_score(audio: np.ndarray, sr: int, frame_ms: int = 20) -> float:
    """
    音量稳定性评分:RMS 方差越小越好
    
    参数:
        audio: 音频数据(numpy 数组)
        sr: 采样率
        frame_ms: 帧长度(毫秒)
    
    返回:
        0~1 的分数
    
    计算步骤:
        1. 将音频分帧
        2. 计算每帧的 RMS 能量
        3. 计算 RMS 序列的方差
        4. 归一化到 0~1 分数
    """
    frame_size = int(sr * frame_ms / 1000)
    if frame_size <= 0:
        return 0.5
    
    frames = len(audio) // frame_size
    if frames < 2:
        return 0.5  # 太短无法评估
    
    rms_values = []
    for i in range(frames):
        frame = audio[i * frame_size : (i + 1) * frame_size]
        rms = np.sqrt(np.mean(frame.astype(np.float64) ** 2))
        rms_values.append(rms)
    
    if len(rms_values) < 2:
        return 0.5
    
    # 归一化 RMS 值(避免绝对值影响)
    rms_array = np.array(rms_values)
    mean_rms = np.mean(rms_array)
    if mean_rms > 0:
        rms_normalized = rms_array / mean_rms
        variance = np.var(rms_normalized)
    else:
        variance = 0
    
    # 归一化:方差越小分数越高
    # 经验阈值:方差 < 0.01 为优秀,> 0.5 为较差
    score = max(0, 1.0 - variance * 2)
    return min(1.0, score)


def f0_variance_score(audio: np.ndarray, sr: int) -> float:
    """
    音高稳定性评分:F0 方差越小越好
    
    参数:
        audio: 音频数据(numpy 数组)
        sr: 采样率
    
    返回:
        0~1 的分数
    
    计算步骤:
        1. 使用 librosa.pyin 提取 F0
        2. 过滤无声帧(F0=NaN)
        3. 转换为音分计算方差
        4. 归一化到 0~1 分数
    """
    try:
        import librosa
    except ImportError:
        logger.warning("librosa 未安装,无法计算 F0 方差")
        return 0.5
    
    try:
        # 提取 F0(使用 pyin 算法)
        f0, voiced_flag, voiced_probs = librosa.pyin(
            audio.astype(np.float32),
            fmin=librosa.note_to_hz('C2'),   # ~65Hz
            fmax=librosa.note_to_hz('C6'),   # ~1047Hz
            sr=sr
        )
        
        # 过滤无效值
        valid_f0 = f0[~np.isnan(f0)]
        
        if len(valid_f0) < 3:
            return 0.5  # 无法评估
        
        # 转换为音分(cents)计算方差,避免频率绝对值影响
        # cents = 1200 * log2(f / f_ref)
        median_f0 = np.median(valid_f0)
        if median_f0 <= 0:
            return 0.5
        
        f0_cents = 1200 * np.log2(valid_f0 / median_f0)
        variance = np.var(f0_cents)
        
        # 归一化:方差 < 100 cents² 为优秀,> 10000 cents² 为较差
        # 100 cents ≈ 1个半音
        score = max(0, 1.0 - variance / 10000)
        return min(1.0, score)
        
    except Exception as e:
        logger.warning(f"F0 计算失败: {e}")
        return 0.5



class QualityScorer:
    """
    音频质量评分器
    
    支持多维度评估和加权综合评分
    """
    
    # 默认权重
    DEFAULT_WEIGHTS = {
        "duration": 0.3,
        "rms": 0.3,
        "f0": 0.4
    }
    
    def __init__(
        self,
        enabled_metrics: Optional[List[str]] = None,
        weights: Optional[Dict[str, float]] = None
    ):
        """
        初始化评分器
        
        参数:
            enabled_metrics: 启用的评分维度,如 ["duration", "rms", "f0"]
            weights: 各维度权重
        """
        self.enabled_metrics = enabled_metrics or ["duration"]
        self.weights = weights or self.DEFAULT_WEIGHTS.copy()
    
    def score(
        self,
        audio: np.ndarray,
        sr: int,
        duration: Optional[float] = None
    ) -> Dict[str, float]:
        """
        计算音频质量分数
        
        参数:
            audio: 音频数据
            sr: 采样率
            duration: 音频时长(秒),如不提供则自动计算
        
        返回:
            包含各维度分数和综合分数的字典
        """
        if duration is None:
            duration = len(audio) / sr
        
        scores = {}
        
        if "duration" in self.enabled_metrics:
            scores["duration"] = duration_score(duration)
        
        if "rms" in self.enabled_metrics:
            scores["rms"] = rms_variance_score(audio, sr)
        
        if "f0" in self.enabled_metrics:
            scores["f0"] = f0_variance_score(audio, sr)
        
        # 计算加权综合分数
        if scores:
            total_weight = sum(self.weights.get(k, 0) for k in scores.keys())
            if total_weight > 0:
                combined = sum(
                    scores[k] * self.weights.get(k, 0) 
                    for k in scores.keys()
                ) / total_weight
            else:
                combined = sum(scores.values()) / len(scores)
            scores["combined"] = combined
        else:
            scores["combined"] = 0.5
        
        return scores
    
    def score_from_file(self, wav_path: str) -> Dict[str, float]:
        """
        从文件计算质量分数
        
        参数:
            wav_path: 音频文件路径
        
        返回:
            包含各维度分数和综合分数的字典
        """
        try:
            import soundfile as sf
            audio, sr = sf.read(wav_path)
            
            # 转换为单声道
            if len(audio.shape) > 1:
                audio = audio.mean(axis=1)
            
            return self.score(audio, sr)
            
        except Exception as e:
            logger.error(f"读取音频文件失败 {wav_path}: {e}")
            return {"combined": 0.5}


def calculate_quality_score(
    audio: np.ndarray,
    sr: int,
    weights: Optional[Dict[str, float]] = None,
    enabled_metrics: Optional[List[str]] = None
) -> float:
    """
    便捷函数:计算综合质量评分
    
    参数:
        audio: 音频数据
        sr: 采样率
        weights: 各维度权重
        enabled_metrics: 启用的评分维度
    
    返回:
        0~1 的综合分数
    """
    scorer = QualityScorer(enabled_metrics=enabled_metrics, weights=weights)
    scores = scorer.score(audio, sr)
    return scores.get("combined", 0.5)