Python 107 维音频+歌词特征提取, 可用于构建基于内容的音频推荐系统

39 阅读12分钟

文章简介

本文介绍关于音频提取、歌词关键词特征提取的工具,工具使用 python 编写,代码依据开源至 Github,仓库 READEME 有完整的使用介绍,仓库内的代码可以直接克隆后使用。 工具还提供 Flask 服务 API,可直接通过网络请求使用。 音频、歌词特征可用于构建基于内容的音乐推荐系统,实测可用

仓库信息

仓库地址:github.com/the-wind-is…

依赖信息

  • librosa:用于提取音频特征
  • numpy:特征向量计算
  • sentence_transformers:用于提取歌词特征
  • scikit-learn:PCA 降维
  • flask:falsk 服务
  • flask-cors:falsk 服务
  • joblib:特征标准化模型保存

音频特征提取

代码位置:core/audio_features.py MFCC 特征:提取 20 维 MFCC 系数及其一阶、二阶差分,共 80 维特征 频谱特征:频谱重心、滚降、带宽、对比度等 6 维特征 时域特征:过零率、自相关峰值等 3 维特征 能量特征:RMS 能量、动态范围等 4 维特征 音乐特征:BPM、色度特征、拍号等 14 维特征 完整特征集:整合所有特征,共 107 维向量

class AudioFeatureExtractor:
    """音频特征提取类"""

    def __init__(self, audio_path: str,
                 sr: float = None,
                 n_fft: int = 2048,
                 hop_length: int = 512,
                 n_mfcc: int = 20,
                 n_mels: int = 256,
                 delta: bool = True,
                 delta2: bool = True,
                 n_bands: int = 6,
                 fmin: float = 200.0,
                 ):
        """
        提取音频文件的MFCC特征

        参数:
        ----------
        audio_path : str
            音频文件路径
        sr : float
            目标采样率,None表示使用原始采样率 (默认: None)
            - 高音质音乐保持44.1kHz
            - 语音内容降采样到16kHz
            - 统一重采样到22.05kHz,平衡音乐和语音需求,计算效率较高
        n_fft : int
            FFT窗口大小,单位为样本数 (默认: 2048),决定频率分辨率和时间分辨率的权衡
            - 音乐分析标准:2048(44.1kHz采样率时对应46ms)
            - 节奏感强的音乐:1024-2048(平衡时间和频率分辨率)
            - 古典/舒缓音乐:4096(更精细的和声分析)
            - 实时推荐:1024(更快计算)
        hop_length : int
            帧移大小,单位为样本数 (默认: 512),决定时间分辨率和特征冗余度
            - 标准设置:512(当n_fft=2048时)
            - 高时间精度:256(适合鼓点分析)
            - 计算优化:1024(适合长音乐推荐)
            - 重叠率公式:hop_length = n_fft / 4(推荐)
        n_mfcc : int
            要提取的MFCC系数数量
            - n_mfcc=13: 语音为主的推荐(播客、有声书)
            - n_mfcc=20: 音乐为主的推荐(歌曲推荐)
            - n_mfcc=26: 混合内容推荐
        n_mels : int
            梅尔滤波器的数量 (默认: 256),决定频谱的压缩程度和特征维度
            - 标准设置:128(平衡细节和计算)
            - 音色敏感推荐:256(如乐器识别推荐)
            - 语音为主推荐:80(节省计算资源)
            - 环境音乐推荐:64-80(关注主要频段)
        delta : bool
            是否计算一阶差分 (Delta MFCC) (默认: True),物理意义:捕捉动态特征
            - 一阶差分,表征MFCC随时间的变化率
            - delta=True:几乎所有音乐推荐都应启用
        delta2 : bool
            是否计算二阶差分 (Delta-Delta MFCC) (默认: False),物理意义:捕捉动态特征
            - 二阶差分,表征变化率的变化(加速度)
        n_bands : int

        fmin : float

        """
        self.audio_path = audio_path
        self.n_fft = n_fft
        self.hop_length = hop_length
        self.n_mfcc = n_mfcc
        self.n_mels = n_mels
        self.delta = delta
        self.delta2 = delta2

        # 添加文件存在性检查
        if not os.path.exists(audio_path):
            raise FileNotFoundError(f"音频文件不存在: {audio_path}")

        # 忽略警告
        import warnings
        warnings.filterwarnings("ignore", message="PySoundFile failed.*")
        warnings.filterwarnings("ignore", category=FutureWarning, module="librosa.*")

        try:
            y, original_sr = librosa.load(audio_path, sr=sr, mono=True)
            print(f"音频加载成功: {audio_path}, original_sr: {original_sr}")
            self.y = y
            self.sr = sr if sr else original_sr
            print(f"采样率: {self.sr} Hz, 音频长度: {len(self.y) / self.sr:.2f} 秒, 样本数: {len(self.y)}")
            # 根据采样率调整频带数量
            self.n_bands = n_bands  # 默认值
            self.fmin = fmin
            if self.sr <= 8000:
                self.n_bands = 4
                self.fmin = 100.0
            elif self.sr <= 16000:
                self.n_bands = 5
                self.fmin = 200.0
        except Exception as e:
            raise RuntimeError(
                f"无法加载音频文件 {audio_path},请检查文件格式或安装必要的音频编解码器。错误详情: {str(e)}")

    def extract_mfcc_features(self) -> np.ndarray:
        """提取 MFCC 特征以及统计量"""
        # 1. MFCC统计特征
        mfccs = librosa.feature.mfcc(y=self.y, sr=self.sr,
                                     n_mfcc=self.n_mfcc,
                                     n_fft=self.n_fft,
                                     hop_length=self.hop_length)
        # 1. 每帧的平均值(最重要的部分),音色基调
        mfcc_mean = np.mean(mfccs, axis=1)
        print(f"MFCC特征 mean 维度: {mfcc_mean.shape}, 前 4 个:{mfcc_mean[:4]}")
        # 标准差,音色变化程度
        mfcc_std = np.std(mfccs, axis=1)
        print(f"MFCC特征 std 维度: {mfcc_std.shape}, 前 4 个:{mfcc_std[:4]}")
        # 2. 全局统计(压缩信息)
        # 一阶差分: 表征MFCC的动态变化
        mfcc_delta = np.mean(librosa.feature.delta(mfccs), axis=1)
        print(f"MFCC特征 delta 维度: {mfcc_delta.shape}, 前 4 个:{mfcc_delta[:4]}")
        # 二阶差分: 表征变化的加速度
        mfcc_delta2 = np.mean(librosa.feature.delta(mfccs, order=2), axis=1)
        print(f"MFCC特征 delta2 维度: {mfcc_delta2.shape}, 前 4 个:{mfcc_delta2[:4]}")
        # 合并:共80维(20×4)
        mfcc_all = np.concatenate([mfcc_mean, mfcc_std, mfcc_delta, mfcc_delta2])
        print(f"MFCC特征总维度: {mfcc_all.shape}")
        return mfcc_all

    def extract_spectral_features(self):
        """提取核心频谱特征"""
        spectral_features = []

        # 1. 频谱重心(Spectral Centroid) - 明亮度
        cent = librosa.feature.spectral_centroid(y=self.y, sr=self.sr, n_fft=self.n_fft, hop_length=self.hop_length)
        spectral_features.append(np.mean(cent))  # 均值:整体明亮度
        spectral_features.append(np.std(cent))  # 标准差:明亮度变化
        print(f"频谱重心 mean: {spectral_features[0]:.4f}, std: {spectral_features[1]:.4f}")

        # 2. 频谱滚降(Spectral Rolloff) - 区分语音/音乐
        rolloff = librosa.feature.spectral_rolloff(y=self.y, sr=self.sr, n_fft=self.n_fft, hop_length=self.hop_length)
        spectral_features.append(np.mean(rolloff))  # 均值
        spectral_features.append(np.std(rolloff))  # 标准差
        print(f"频谱滚降 mean: {spectral_features[2]:.4f}, std: {spectral_features[3]:.4f}")

        # 3. 频谱带宽(Spectral Bandwidth) - 频谱宽度
        bandwidth = librosa.feature.spectral_bandwidth(y=self.y, sr=self.sr, n_fft=self.n_fft,
                                                       hop_length=self.hop_length)
        spectral_features.append(np.mean(bandwidth))  # 均值
        print(f"频谱带宽 mean: {spectral_features[4]:.4f}")

        # 4. 频谱对比度(Spectral Contrast) - 谐波突出度(可选)
        contrast = librosa.feature.spectral_contrast(
            y=self.y,
            sr=self.sr,
            n_fft=self.n_fft,
            hop_length=self.hop_length,
            fmin=self.fmin,
            n_bands=self.n_bands
        )
        spectral_features.append(np.mean(contrast))  # 整体对比度
        print(f"频谱对比度 mean: {spectral_features[5]:.4f}")

        # 共 6 维
        features = np.array(spectral_features)
        print(f"频谱特征维度: {features.shape}")
        return features

    def extract_temporal_features(self):
        """提取时域特征"""
        temporal_features = []

        # 1. 过零率(Zero Crossing Rate) - 打击乐/持续音区分
        zcr = librosa.feature.zero_crossing_rate(self.y)
        temporal_features.append(np.mean(zcr))  # 均值:总体"尖锐度"
        temporal_features.append(np.std(zcr))  # 标准差:变化模式
        print(f"过零率 mean: {temporal_features[0]:.4f}, std: {temporal_features[1]:.4f}")

        # 2. 自相关峰值 - 节奏规律性(比直接RMS更有用)
        # 计算短时自相关找周期性
        autocorr = librosa.autocorrelate(self.y[:min(len(self.y), 44100)])  # 取前1秒
        if len(autocorr) > 10:
            # 找第一个显著峰值的位置(节奏周期)
            peaks = librosa.util.peak_pick(x=autocorr[10:], pre_max=10, post_max=10,
                                           pre_avg=10, post_avg=10, delta=0.5, wait=10)
            if len(peaks) > 0:
                temporal_features.append(peaks[0] / self.sr)  # 周期长度
            else:
                temporal_features.append(0.0)
        else:
            temporal_features.append(0.0)
        print(f"自相关峰值 mean: {temporal_features[2]:.4f}")

        # 共 3 维
        features = np.array(temporal_features)
        print(f"时域特征维度: {features.shape}")
        return features

    def extract_energy_features(self):
        """提取能量特征"""
        energy_features = []

        # 1. RMS能量 - 整体响度
        rms = librosa.feature.rms(y=self.y, frame_length=self.n_fft, hop_length=self.hop_length)
        energy_features.append(np.mean(rms))  # 均值:平均响度
        energy_features.append(np.std(rms))  # 标准差:动态范围
        energy_features.append(np.max(rms))  # 最大值:最强部分
        print(f"RMS特征 mean: {energy_features[0]:.4f}, std: {energy_features[1]:.4f}, max: {energy_features[2]:.4f}")

        # 2. 能量包络特征 - 歌曲结构
        # 计算能量上升/下降时间
        rms_norm = rms / (np.max(rms) + 1e-6)
        # 能量超过0.5的时间比例
        energy_above_half = np.sum(rms_norm > 0.5) / len(rms_norm)
        energy_features.append(energy_above_half)
        print(f"能量包络特征 mean: {energy_features[3]:.4f}")

        # 共 4 维
        features = np.array(energy_features)
        print(f"能量特征维度: {features.shape}")
        return features

    def extract_music_features(self):
        """提取音乐领域特征"""
        music_features = []

        # 1. 节奏(Tempo)- BPM
        tempo, _ = librosa.beat.beat_track(y=self.y, sr=self.sr, hop_length=self.hop_length)
        music_features.append(tempo[0] if len(tempo) > 0 else 120)
        print(f"BPM: {music_features[0]:.4f}")

        # 2. 色度特征(Chroma) - 和声特征
        chroma = librosa.feature.chroma_stft(y=self.y, sr=self.sr)
        # 取12个音级的平均值, 共 12 维
        chroma_mean = np.mean(chroma, axis=1)
        print(f"chroma_mean: {chroma_mean.shape}")
        music_features.extend(chroma_mean)
        print(f"色度特征 mean: {music_features[1]:.4f}, {music_features[2]:.4f}, ..., {music_features[12]:.4f}")

        # 3. 拍号(估计) - 节奏模式
        onset_env = librosa.onset.onset_strength(y=self.y, sr=self.sr)
        pulse = librosa.beat.plp(onset_envelope=onset_env, sr=self.sr)
        music_features.append(np.mean(pulse))  # 1维
        print(f"拍号特征 mean: {music_features[13]:.4f}")

        features = np.array(music_features)
        print(f"音乐特征维度: {features.shape}")
        return features

    def extract_all_features(self):
        """完整特征提取函数"""

        features = []

        # 2. 提取各类特征
        print("-" * 60 + " 1. 提取 MFCC 特征 " + "-" * 60)
        features.extend(self.extract_mfcc_features())  # 80维
        print("-" * 60 + " 2. 提取 频谱特征 " + "-" * 60)
        features.extend(self.extract_spectral_features())  # 6维
        print("-" * 60 + " 3. 提取 时域特征 " + "-" * 60)
        features.extend(self.extract_temporal_features())  # 3维
        print("-" * 60 + " 4. 提取 能量特征 " + "-" * 60)
        features.extend(self.extract_energy_features())  # 4维
        print("-" * 60 + " 5. 提取 音乐特征 " + "-" * 60)
        features.extend(self.extract_music_features())  # 14维

        # 3. 可选:简单元数据特征
        # 如果有标签信息,可以添加
        # features.extend(genre_one_hot)  # 例如流派独热编码

        # 总共约 107 维
        features = np.array(features)
        print(f"特征向量形状: {features.shape}")
        return features


if __name__ == '__main__':
    print("\n" + '=' * 120)
    audio_file = "../测试数据/小村庄月弯弯-晚月moon.mp3"
    # 创建特征提取器
    extractor = AudioFeatureExtractor(audio_file)
    # 提取特征
    features = extractor.extract_all_features()
    print(f'{audio_file} 特征向量形状: {features.shape}')
    print(f'{audio_file}\n'
          f'前 3 维: {json.dumps(features[:3], cls=CustomEncoder)}\n'
          f'后 3 维: {json.dumps(features[-3:], cls=CustomEncoder)}')

    print("\n" + '=' * 120)
    audio_file = "../测试数据/情火-洋澜一.m4a"
    extractor = AudioFeatureExtractor(audio_file)
    features = extractor.extract_all_features()
    print(f'{audio_file} 特征向量形状: {features.shape}')
    print(f'{audio_file}\n'
          f'前 3 维: {json.dumps(features[:3], cls=CustomEncoder)}\n'
          f'后 3 维: {json.dumps(features[-3:], cls=CustomEncoder)}')

歌词特征提取

代码位置:core/lyric_analyzer.py 使用轻量级 BERT 模型(paraphrase-MiniLM-L6-v2)获取歌词语义向量 支持将 384 维语义向量降维至目标维度 支持批量处理歌词关键词

class LyricsProcessor:
    """歌词处理器"""
    _instance = None
    _bert_model = None

    def __new__(cls):
        if cls._instance is None:
            cls._instance = super().__new__(cls)
        return cls._instance

    def __init__(self):
        import os
        # 设置镜像源(必须在导入其他库之前)可选
        os.environ['HF_ENDPOINT'] = 'https://hf-mirror.com'
        # 使用轻量级BERT模型获取歌词语义向量
        from sentence_transformers import SentenceTransformer
        self._bert_model = SentenceTransformer('paraphrase-MiniLM-L6-v2')

    def bert_encode(self, lyrics_list: str | list[str] | np.ndarray):
        """将关键词列表转化为文本并编码"""
        return self._bert_model.encode(lyrics_list)

    def reduce_lyrics_dimension(self, lyrics_embeddings, target_dim):
        """将384维的语义向量降到与音频向量相近的维度"""
        # 方法A:PCA降维
        from sklearn.decomposition import PCA
        pca = PCA(n_components=target_dim, random_state=42)
        lyrics_reduced = pca.fit_transform(lyrics_embeddings)

        # 方法B:UMAP降维(保持局部结构)
        # reducer = umap.UMAP(n_components=target_dim, random_state=42)
        # lyrics_reduced = reducer.fit_transform(lyrics_embeddings)

        return lyrics_reduced


if __name__ == "__main__":
    max_features = 86

    # 歌词关键词可通过 AI 获取
    with open('../测试数据/歌词关键词列表.json', 'r', encoding='utf-8') as f:
        lyrics_keywords_list = json.load(f)

    print("\n" + "=" * 60 + " 歌词关键词特征批量获取 " + "=" * 60)
    processor = LyricsProcessor()
    lyrics_embeddings = processor.bert_encode(lyrics_keywords_list)
    print(f"歌词特征维度: {lyrics_embeddings.shape}")

    print("\n" + "=" * 60 + " 降维 " + "=" * 60)
    lyrics_reduced = processor.reduce_lyrics_dimension(lyrics_embeddings, target_dim=max_features)
    print(f"降维后的歌词特征维度: {lyrics_reduced.shape}")

特征标准化

音频、歌词特征由于特征量纲不同,向量变化范围极大,需要进行特征标准化 基于 sklearn 实现特征标准化 支持单个特征和批量特征的标准化处理 支持标准化器的保存和加载 提供 PCA 特征降维功能

class Standardizer:
    """数据标准化类"""

    def __init__(self, scaler_path: str):
        """
        :param scaler_path: 标准化器保存路径,用于后续新数据
        """
        self.scaler_path = scaler_path
        if scaler_path and os.path.exists(scaler_path):
            self.scaler = joblib.load(scaler_path)

    def init_standardizer(self, features_matrix: np.ndarray):
        """
        初始化阶段:基于足够的训练数据计算标准化参数
        参数:
            features_matrix: numpy数组,形状为 (n_samples, n_features)
        返回:
            标准化后的特征矩阵
        """
        # 1. 初始化标准化器
        from sklearn.preprocessing import StandardScaler
        self.scaler = StandardScaler()

        # 2. 计算并应用标准化
        # fit_transform = 计算每个特征的均值和标准差,然后进行转换
        features_scaled = self.scaler.fit_transform(features_matrix)

        # 3. 验证标准化效果
        print("-" * 60 + " 标准化验证 " + "-" * 60)
        print(f"原始数据 - 各特征均值: {np.mean(features_matrix, axis=0)[:5]}")  # 显示前5个特征
        print(f"原始数据 - 各特征标准差: {np.std(features_matrix, axis=0)[:5]}")
        print(f"标准化后 - 各特征均值: {np.mean(features_scaled, axis=0)[:5]}")
        print(f"标准化后 - 各特征标准差: {np.std(features_scaled, axis=0)[:5]}")
        return features_scaled

    def save(self):
        """保存标准化器"""
        with open(self.scaler_path, 'wb') as f:
            joblib.dump(self.scaler, f)

    def process_new_features(self, new_song_features: np.ndarray):
        """
        处理新特征的特征标准化
        参数:
            new_song_features: 新特征的原始特征向量 (1, n_features)
        返回:
            标准化后的特征向量
        """
        if not self.scaler:
            raise ValueError("请先初始化标准化器 or 加载已有标准化器")
        # 应用相同的标准化变换
        new_features_scaled = self.scaler.transform(new_song_features.reshape(1, -1))
        return new_features_scaled.flatten()

    def batch_process_new_features(self, new_songs_features: np.ndarray):
        """
        批量处理新特征的特征标准化
        参数:
            new_songs_features: 新特征的批量特征向量 (n_songs, n_features)
        返回:
            标准化后的特征向量
        """
        # 应用相同的标准化变换
        new_features_scaled = self.scaler.transform(new_songs_features)
        return new_features_scaled

    @staticmethod
    def reduce_dimension(embeddings, target_dim):
        """将向量维度降低到目标维度"""
        # 方法A:PCA降维
        from sklearn.decomposition import PCA
        pca = PCA(n_components=target_dim, random_state=42)
        return pca.fit_transform(embeddings)


if __name__ == "__main__":
    max_features = 86

    # 获取歌词特征
    with open('../测试数据/歌词关键词列表.json', 'r', encoding='utf-8') as f:
        lyrics_keywords_list = json.load(f)

    print("\n" + "=" * 60 + " 歌词关键词特征批量获取 " + "=" * 60)
    processor = LyricsProcessor()
    lyrics_embeddings = processor.bert_encode(lyrics_keywords_list)
    print(f"歌词特征维度: {lyrics_embeddings.shape}")

    print("\n" + "=" * 60 + " 降维 " + "=" * 60)
    lyrics_reduced = processor.reduce_lyrics_dimension(lyrics_embeddings, target_dim=max_features)
    print(f"降维后的歌词特征维度: {lyrics_reduced.shape}")

    # 特征标准化
    print("\n" + "=" * 60 + " 特征标准化 " + "=" * 60)
    features_matrix = lyrics_reduced
    scaler_path = "../测试数据/standardizer.pkl"
    standardizer = Standardizer(scaler_path)
    features_scaled = standardizer.init_standardizer(np.array(features_matrix))
    print(f"标准化后的特征维度: {features_scaled.shape}")
    standardizer.save()

相似度计算

计算特征之间的相似度,用于音频推荐,值越大表示越相似 基于余弦相似度算法

def cosine_similarity(a, b):
    """
    计算两个向量之间的余弦相似度

    参数:
    a, b : array_like
        输入的两个向量

    返回:
    float
        两向量间的余弦相似度值,范围 [-1, 1]
    """
    # 确保输入为 numpy 数组
    if not isinstance(a, np.ndarray):
        a = np.array(a, dtype=np.float64)
    if not isinstance(b, np.ndarray):
        b = np.array(b, dtype=np.float64)
    # 检查维度是否匹配
    if a.shape != b.shape:
        raise ValueError("向量维度不匹配")
    # 计算点积
    dot_product = np.dot(a, b)

    # 计算各向量的模长(L2范数)
    norm_a = np.linalg.norm(a)
    norm_b = np.linalg.norm(b)

    # 避免除零错误
    if norm_a == 0 or norm_b == 0:
        return 0.0

    # 计算余弦相似度
    similarity = dot_product / (norm_a * norm_b)

    return similarity


def one_to_more_cosine_similarity(source_vec: np.ndarray, targets_mat: np.ndarray):
    """
    计算一个向量与多个向量的余弦相似度
    :param source_vec:  源向量 (n,)
    :param targets_mat: 目标向量矩阵(m,n)
    :return:
    """

    # 快速检查维度
    if source_vec.ndim != 1:
        raise Exception("源向量维度错误,源向量必须是(n,)")

    if targets_mat.ndim != 2 or targets_mat.shape[1] != source_vec.shape[0]:
        raise Exception("目标向量矩阵维度错误, 目标向量矩阵必须为(m,n)")

    # 计算模长
    source_norm = np.linalg.norm(source_vec)

    # 特殊情况:源向量为零向量
    if source_norm == 0:
        return np.zeros(targets_mat.shape[0])

    # 向量化计算点积
    dot_products = np.dot(targets_mat, source_vec)

    # 计算目标向量的模长
    targets_norm = np.linalg.norm(targets_mat, axis=1)

    # 避免除零(处理目标向量的零向量)
    EPSILON = 1e-10
    denominator = source_norm * targets_norm
    mask = denominator > EPSILON

    # 初始化结果数组
    similarities = np.zeros(targets_mat.shape[0])
    similarities[mask] = dot_products[mask] / denominator[mask]

    return similarities


if __name__ == '__main__':
    print('\n' + '=' * 40 + ' 测试向量余弦相似度 ' + '=' * 40)
    a = np.array([1, 2, 3])
    b = np.array([4, 5, 6])
    similarity = cosine_similarity(a, b)
    print(f'similarity:{similarity}')

    print('\n' + '=' * 40 + ' 测试矩阵余弦相似度 ' + '=' * 40)
    source_vec = np.array([1, 2, 3])
    target_vec = np.array([[7, 8, 9], [10, 11, 12]])
    similarities = one_to_more_cosine_similarity(source_vec, target_vec)
    print(f'similarities:{similarities}')

Web 服务

Web 服务基于 Flask 框架,使用我之前开源的模版。 Flask 服务模版仓库地址:github.com/the-wind-is… 模版介绍:juejin.cn/post/758284…

源码仓库

仓库地址:github.com/the-wind-is…