Commit 6b9f5ef7 6b9f5ef705542ce4f52bed71c9b3b9b380dc0792 by 沈秋雨

update

1 parent 974df4ae
Showing 45 changed files with 2534 additions and 29 deletions
......@@ -28,4 +28,5 @@ test_api
composition_dedup/composition_eval
composition_testset
\ No newline at end of file
composition_testset
acrcloud_testset_cloud
\ No newline at end of file
......
......@@ -21,11 +21,7 @@ from pathlib import Path
import librosa
import numpy as np
from scipy.ndimage import (
generate_binary_structure,
iterate_structure,
maximum_filter,
)
from scipy.ndimage import generate_binary_structure, iterate_structure, maximum_filter
from scipy.signal import spectrogram
logger = logging.getLogger(__name__)
......@@ -54,7 +50,7 @@ DEFAULT_WINDOW_SIZE = 4096
DEFAULT_OVERLAP_RATIO = float(os.environ.get("COMPOSITION_DEJAVU_OVERLAP_RATIO", "0.3"))
DEFAULT_FAN_VALUE = int(os.environ.get("COMPOSITION_DEJAVU_FAN_VALUE", "10"))
DEFAULT_AMP_MIN = float(os.environ.get("COMPOSITION_DEJAVU_AMP_MIN", "20"))
PEAK_NEIGHBORHOOD_SIZE = 20
PEAK_NEIGHBORHOOD_SIZE = int(os.environ.get("COMPOSITION_DEJAVU_PEAK_NEIGHBORHOOD_SIZE", "20"))
MIN_HASH_TIME_DELTA = 0
MAX_HASH_TIME_DELTA = 200
PEAK_SORT = True
......@@ -128,10 +124,9 @@ def _get_2d_peaks(arr2D: np.ndarray, amp_min: float = DEFAULT_AMP_MIN):
Returns:
(frequency_idx, time_idx): 峰值的频率和时间索引列表
"""
# 找局部极大值(菱形邻域,L1 球,4连通膨胀 PEAK_NEIGHBORHOOD_SIZE 次)
struct = generate_binary_structure(2, 1)
neighborhood = iterate_structure(struct, PEAK_NEIGHBORHOOD_SIZE)
# 找局部极大值
detected_peaks = maximum_filter(arr2D, footprint=neighborhood) == arr2D
# 提取峰值
......
......@@ -6,6 +6,9 @@
3. 主音对齐:将能量最大的音级滚至第 0 行,实现转调不变性
4. scipy.signal.resample(chroma, 128, axis=1) 时间归一化到 12×128
5. .flatten() 展开为 1536 维向量
子序列 DTW 路径(extract_chroma_fixed_fps_from_samples)跳过第 4、5 步,
改用线性插值到固定帧率,保留音频时长信息供子序列匹配使用。
"""
import logging
......@@ -120,3 +123,72 @@ def extract_chroma_matrix(audio_path: str, hop_length: int = 512, win_len_smooth
shape 为 (12, 128) 的 numpy 数组,已做主音对齐。
"""
return extract_chroma_feature(audio_path, hop_length=hop_length, win_len_smooth=win_len_smooth).reshape(12, TARGET_FRAMES)
# 子序列 DTW 路径的默认目标帧率(帧/秒)。
# fps=2 → 每 0.5s 一帧;3 分钟歌曲 = 360 帧;副歌片段(40%)= 144 帧。
FIXED_FPS = 2
def load_audio_mono_22050hz(audio_path: str) -> np.ndarray:
"""从音频文件加载 22050Hz 单声道 float32 样本(对外公开接口)。
直接调用内部的 ffmpeg pipe 解码,无临时文件。
Raises:
FileNotFoundError: 音频文件不存在。
RuntimeError: ffmpeg 解码失败。
"""
if not os.path.isfile(audio_path):
raise FileNotFoundError(f"音频文件不存在: {audio_path}")
return _load_audio_via_pipe(audio_path)
def extract_chroma_fixed_fps_from_samples(
samples: np.ndarray,
sr: int,
target_fps: int = FIXED_FPS,
hop_length: int = 512,
win_len_smooth: int = 41,
) -> np.ndarray:
"""提取固定帧率 Chromagram 矩阵,帧数由音频时长决定(供子序列 DTW 使用)。
与 extract_chroma_feature_from_samples 的区别:
- 不做固定 128 帧归一化,保留音频绝对时长信息
- 时间轴插值改用线性插值,避免 FFT 重采样(scipy.signal.resample)的 Gibbs 振铃
- 返回 12×T 矩阵,T = round(duration_sec * target_fps)
- 查询片段(如 chorus_only)与全曲均保持同等时间密度(帧/秒),
子序列 DTW 可在全曲序列中找到最佳对齐窗口
Args:
samples: 单声道音频样本(任意采样率)。
sr: samples 对应的采样率。
target_fps: 目标帧率(帧/秒),决定最终时间分辨率。
hop_length: CQT hop 大小。
win_len_smooth: CENS 平滑窗口帧数。
Returns:
shape 为 (12, T) 的 numpy float32 数组,未做主音对齐(由调用方的 12 路位移处理)。
"""
y = samples if sr == TARGET_SR else librosa.resample(samples, orig_sr=sr, target_sr=TARGET_SR)
chroma = librosa.feature.chroma_cens(
y=y, sr=TARGET_SR, hop_length=hop_length, win_len_smooth=win_len_smooth
)
# 不做主音对齐:固定帧率路径依赖 _best_shifted_subsequence_dtw 的 12 路遍历
# 实现转调不变性,argmax 能量估计不稳定,去掉后向量空间更干净
# 目标帧数由音频时长和目标帧率共同决定
duration_sec = len(y) / TARGET_SR
target_t = max(1, round(duration_sec * target_fps))
# 线性插值到目标帧数
if chroma.shape[1] != target_t:
x_old = np.linspace(0, 1, chroma.shape[1])
x_new = np.linspace(0, 1, target_t)
chroma = np.array(
[np.interp(x_new, x_old, chroma[p]) for p in range(12)],
dtype=np.float32,
)
return chroma.astype(np.float32)
......
......@@ -22,6 +22,11 @@ pgvector>=0.2.0
# HTTP API server
fastapi>=0.110.0
uvicorn[standard]>=0.29.0
python-multipart>=0.0.9
# Environment variable loading
python-dotenv>=1.0
# Aliyun OSS and ICE SDK
oss2>=2.18.0
alibabacloud_ice20201109>=4.0.0
......
{
"total": 240,
"filters": {
"variants": null,
"sample_classes": null,
"expected": null,
"original_total": 240
},
"duplicate_threshold": 0.8,
"accuracy": 0.975,
"precision": 1.0,
"recall": 0.9625,
"f1": 0.9809,
"tp": 154,
"fp": 0,
"tn": 80,
"fn": 6,
"by_variant": {
"acr_original": {
"accuracy": 1.0,
"total": 20
},
"codec_320k": {
"accuracy": 1.0,
"total": 20
},
"eq_bass_boost": {
"accuracy": 1.0,
"total": 20
},
"eq_mid": {
"accuracy": 0.95,
"total": 20
},
"negative_original": {
"accuracy": 1.0,
"total": 80
},
"noise_floor": {
"accuracy": 1.0,
"total": 20
},
"reverb_small": {
"accuracy": 1.0,
"total": 20
},
"slight_trim": {
"accuracy": 0.75,
"total": 20
},
"sr_16k": {
"accuracy": 1.0,
"total": 20
}
},
"out": "results/aliyun_dna_eval.csv"
}
\ No newline at end of file
import json, os
from pathlib import Path
sys_path = str(Path(__file__).resolve().parent.parent.parent)
import sys; sys.path.insert(0, sys_path)
# 加载 .env
env_path = Path(__file__).resolve().parent.parent.parent / ".env"
with env_path.open(encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, value = line.split("=", 1)
key = key.strip()
if key:
os.environ.setdefault(key, value.strip().strip('"').strip("'"))
from acrcloud.recognizer import ACRCloudRecognizer
config = {
"host": "identify-cn-north-1.acrcloud.cn",
"access_key": os.environ["ACRCLOUD_ACCESS_KEY"],
"access_secret": os.environ["ACRCLOUD_ACCESS_SECRET"],
"timeout": 10,
}
re = ACRCloudRecognizer(config)
result = re.recognize_by_file("/Volumes/移动硬盘/composition_test/202930109_GA000376.wav", 0, 10)
print(json.dumps(json.loads(result), ensure_ascii=False, indent=2))
Usage: ./acrcloud_extr [options] -i <infile>
General Options:
-h, --help Print this help message
-v, --version Print version information
Advanced Options:
-i Path to the input audio file.
-s Start time (in seconds) to skip from the beginning. Default: 0.
Example: ./acrcloud_extr -s 30 -i input.mp3
-l Duration (in seconds) of audio to process.
Default: 12s (for recognition fingerprint).
Note: This is ignored if generating a DB fingerprint.
Example: ./acrcloud_extr -l 12 -i input.mp3
-o Path to the output file.
Default: Saves to the same directory as the input file.
-cli Generate a recognition (query) fingerprint instead of a DB fingerprint.
Example: ./acrcloud_extr -cli -i input.mp3
-type Fingerprint type identifier. Default: 0.
0: standard audio fingerprint;
200: large filter audio fingerprint;
Example: ./acrcloud_extr -type 0 -i input.mp3
-au Export decoded audio data (Format: RIFF WAV, PCM, 16-bit, Mono, 8000Hz).
Example: ./acrcloud_extr -au -i input.mp3
--debug Enable debug mode to print detailed runtime information.
#*******************************************************#
MD5 (acrcloud_extr_linux_arm64) = cafaa6105cbfff4387e50beb229c8b56
MD5 (acrcloud_extr_linux_arm64_alpine) = 1ced0ca9e0b7c1b0a3848fcbe0c7b6ab
MD5 (acrcloud_extr_linux_x86-x64) = a869c8b3e2a0ac29bd1e902e5dafba50
MD5 (acrcloud_extr_linux_x86-x64_alpine) = 5d9dd7c2665facca498d12b028767d18
MD5 (acrcloud_extr_mac) = c59e6a64ad9c6aa62079d34c74691bfa
MD5 (acrcloud_extr_win.exe) = 501460707e10f23cbe396debdcd0a41a
调用CancelDNAJob取消DNA作业。
## 接口说明
- 本接口只能取消状态处于“排队中”状态的作业;
- 建议先调用更新管道接口(**UpdatePipeline**)将管道状态置为 Paused,暂停作业调度,再调用取消作业接口取消作业;取消完后需要恢复管道状态为 Active,管道中的作业才会被调度执行。
## 调试
[您可以在OpenAPI Explorer中直接运行该接口,免去您计算签名的困扰。运行成功后,OpenAPI Explorer可以自动生成SDK代码示例。](https://api.aliyun.com/api/ICE/2020-11-09/CancelDNAJob)
[![](https://img.alicdn.com/tfs/TB16JcyXHr1gK0jSZR0XXbP8XXa-24-26.png)调试](https://api.aliyun.com/api/ICE/2020-11-09/CancelDNAJob)
## 授权信息
下表是API对应的授权信息,可以在RAM权限策略语句的`Action`元素中使用,用来给RAM用户或RAM角色授予调用此API的权限。具体说明如下:
- 操作:是指具体的权限点。
- 访问级别:是指每个操作的访问级别,取值为写入(Write)、读取(Read)或列出(List)。
- 资源类型:是指操作中支持授权的资源类型。具体说明如下:
- 对于必选的资源类型,用前面加 \* 表示。
- 对于不支持资源级授权的操作,用`全部资源`表示。
- 条件关键字:是指云产品自身定义的条件关键字。
- 关联操作:是指成功执行操作所需要的其他权限。操作者必须同时具备关联操作的权限,操作才能成功。
| 操作 | 访问级别 | 资源类型 | 条件关键字 | 关联操作 |
| --- | --- | --- | --- | --- |
| ice:CancelDNAJob | | \\*全部资源 `*` | 无 | 无 |
## 请求参数
| 名称 | 类型 | 必填 | 描述 | 示例值 |
| --- | --- | --- | --- | --- |
| JobId | string | 是 | 取消 DNA 作业 ID。 | 2288c6ca184c0e47098a5b665e2a12\\*\\*\\*\\* |
## 返回参数
| 名称 | 类型 | 描述 | 示例值 |
| --- | --- | --- | --- |
| | object | Schema of Response | |
| RequestId | string | 请求 ID。 | 25818875-5F78-4A13-BEF6-D7393642CA58 |
| JobId | string | 作业 ID。 | 2288c6ca184c0e47098a5b665e2a12\\*\\*\\*\\* |
## 示例
正常返回示例
`JSON`格式
```
{
"RequestId": "25818875-5F78-4A13-BEF6-D7393642CA58",
"JobId": "2288c6ca184c0e47098a5b665e2a12****"
}
```
## 错误码
访问[错误中心]( https://api.aliyun.com/document/ICE/2020-11-09/errorCode)查看更多错误码。
## 变更历史
| 变更时间 | 变更内容概要 | 操作 |
| --- | --- | --- |
| 2023-05-25 | API 内部配置变更,不影响调用 | [查看变更详情](https://api.aliyun.com/document/ICE/2020-11-09/CancelDNAJob?updateTime=2023-05-25#workbench-doc-change-demo) |
\ No newline at end of file
调用DeleteDNAFiles删除DNA文件。
## 调试
[您可以在OpenAPI Explorer中直接运行该接口,免去您计算签名的困扰。运行成功后,OpenAPI Explorer可以自动生成SDK代码示例。](https://api.aliyun.com/api/ICE/2020-11-09/DeleteDNAFiles)
[![](https://img.alicdn.com/tfs/TB16JcyXHr1gK0jSZR0XXbP8XXa-24-26.png)调试](https://api.aliyun.com/api/ICE/2020-11-09/DeleteDNAFiles)
## 授权信息
下表是API对应的授权信息,可以在RAM权限策略语句的`Action`元素中使用,用来给RAM用户或RAM角色授予调用此API的权限。具体说明如下:
- 操作:是指具体的权限点。
- 访问级别:是指每个操作的访问级别,取值为写入(Write)、读取(Read)或列出(List)。
- 资源类型:是指操作中支持授权的资源类型。具体说明如下:
- 对于必选的资源类型,用前面加 \* 表示。
- 对于不支持资源级授权的操作,用`全部资源`表示。
- 条件关键字:是指云产品自身定义的条件关键字。
- 关联操作:是指成功执行操作所需要的其他权限。操作者必须同时具备关联操作的权限,操作才能成功。
| 操作 | 访问级别 | 资源类型 | 条件关键字 | 关联操作 |
| --- | --- | --- | --- | --- |
| ice:DeleteDNAFiles | | \\*全部资源 `*` | 无 | 无 |
## 请求参数
| 名称 | 类型 | 必填 | 描述 | 示例值 |
| --- | --- | --- | --- | --- |
| DBId | string | 是 | 需要删除文件的 DNA 库 ID。 | fb712a6890464059b1b2ea7c8647\\*\\*\\*\\* |
| PrimaryKeys | string | 是 | 需要删除的文件主键,用半角逗号(,)分隔,一次最多删除 50 个。 | 41e6536e4f2250e2e9bf26cdea19\\*\\*\\*\\* |
## 返回参数
| 名称 | 类型 | 描述 | 示例值 |
| --- | --- | --- | --- |
| | object | | |
| RequestId | string | 请求 ID。 | 31E30781-9495-5E2D-A84D-759B0A01E262 |
## 示例
正常返回示例
`JSON`格式
```
{
"RequestId": "31E30781-9495-5E2D-A84D-759B0A01E262"
}
```
## 错误码
访问[错误中心]( https://api.aliyun.com/document/ICE/2020-11-09/errorCode)查看更多错误码。
## 变更历史
| 变更时间 | 变更内容概要 | 操作 |
| --- | --- | --- |
| 2023-05-25 | API 内部配置变更,不影响调用 | [查看变更详情](https://api.aliyun.com/document/ICE/2020-11-09/DeleteDNAFiles?updateTime=2023-05-25#workbench-doc-change-demo) |
\ No newline at end of file
调用ListDNAFiles查询DNA文件。
## 接口说明
本接口通过 DNA 库 ID 查询文件列表,支持分页查询。
## 调试
[您可以在OpenAPI Explorer中直接运行该接口,免去您计算签名的困扰。运行成功后,OpenAPI Explorer可以自动生成SDK代码示例。](https://api.aliyun.com/api/ICE/2020-11-09/ListDNAFiles)
[![](https://img.alicdn.com/tfs/TB16JcyXHr1gK0jSZR0XXbP8XXa-24-26.png)调试](https://api.aliyun.com/api/ICE/2020-11-09/ListDNAFiles)
## 授权信息
下表是API对应的授权信息,可以在RAM权限策略语句的`Action`元素中使用,用来给RAM用户或RAM角色授予调用此API的权限。具体说明如下:
- 操作:是指具体的权限点。
- 访问级别:是指每个操作的访问级别,取值为写入(Write)、读取(Read)或列出(List)。
- 资源类型:是指操作中支持授权的资源类型。具体说明如下:
- 对于必选的资源类型,用前面加 \* 表示。
- 对于不支持资源级授权的操作,用`全部资源`表示。
- 条件关键字:是指云产品自身定义的条件关键字。
- 关联操作:是指成功执行操作所需要的其他权限。操作者必须同时具备关联操作的权限,操作才能成功。
| 操作 | 访问级别 | 资源类型 | 条件关键字 | 关联操作 |
| --- | --- | --- | --- | --- |
| ice:ListDNAFiles | | \\*全部资源 `*` | 无 | 无 |
## 请求参数
| 名称 | 类型 | 必填 | 描述 | 示例值 |
| --- | --- | --- | --- | --- |
| NextPageToken | string | 否 | 分页查询。请求第一页时,NextPageToken 为空;请求后续文件时需传入前一页查询结果中的 NextPageToken 值。 | ae0fd49c0840e14daf0d66a75b83\\*\\*\\*\\* |
| PageSize | integer | 否 | 单页数据个数,默认为 20,最大 100。 | 10 |
| DBId | string | 是 | DNA 库 Id。 | 2288c6ca184c0e47098a5b665e2a12\\*\\*\\*\\* |
## 返回参数
| 名称 | 类型 | 描述 | 示例值 |
| --- | --- | --- | --- |
| | object | | |
| RequestId | string | 请求 ID。 | 2AE89FA5-E620-56C7-9B80-75D09757385A |
| NextPageToken | string | 下一页 Token。 | ae0fd49c0840e14daf0d66a75b83\\*\\*\\*\\* |
| FileList | array<object> | 文件列表。 | |
| DNAFile | object | DNAFile | |
| PrimaryKey | string | DNA 文件用户主键。 | ae0fd49c0840e14daf0d66a75b83\\*\\*\\*\\* |
| InputFile | object | 输入文件 OSS 信息。 | |
| Object | string | 输入文件的 OSS Object。 | example-\\*\\*\\*\\*.mp4 |
| Location | string | 输入文件的 OSS Location。 | oss-cn-beijing |
| Bucket | string | 输入文件的 OSS Bucket。 | example-bucket |
## 示例
正常返回示例
`JSON`格式
```
{
"RequestId": "2AE89FA5-E620-56C7-9B80-75D09757385A",
"NextPageToken": "ae0fd49c0840e14daf0d66a75b83****",
"FileList": [
{
"PrimaryKey": "ae0fd49c0840e14daf0d66a75b83****",
"InputFile": {
"Object": "example-****.mp4",
"Location": "oss-cn-beijing",
"Bucket": "example-bucket"
}
}
]
}
```
## 错误码
访问[错误中心]( https://api.aliyun.com/document/ICE/2020-11-09/errorCode)查看更多错误码。
## 变更历史
| 变更时间 | 变更内容概要 | 操作 |
| --- | --- | --- |
| 2023-05-25 | API 内部配置变更,不影响调用 | [查看变更详情](https://api.aliyun.com/document/ICE/2020-11-09/ListDNAFiles?updateTime=2023-05-25#workbench-doc-change-demo) |
\ No newline at end of file
调用QueryDNAJobList查询DNA作业。
## 调试
[您可以在OpenAPI Explorer中直接运行该接口,免去您计算签名的困扰。运行成功后,OpenAPI Explorer可以自动生成SDK代码示例。](https://api.aliyun.com/api/ICE/2020-11-09/QueryDNAJobList)
[![](https://img.alicdn.com/tfs/TB16JcyXHr1gK0jSZR0XXbP8XXa-24-26.png)调试](https://api.aliyun.com/api/ICE/2020-11-09/QueryDNAJobList)
## 授权信息
下表是API对应的授权信息,可以在RAM权限策略语句的`Action`元素中使用,用来给RAM用户或RAM角色授予调用此API的权限。具体说明如下:
- 操作:是指具体的权限点。
- 访问级别:是指每个操作的访问级别,取值为写入(Write)、读取(Read)或列出(List)。
- 资源类型:是指操作中支持授权的资源类型。具体说明如下:
- 对于必选的资源类型,用前面加 \* 表示。
- 对于不支持资源级授权的操作,用`全部资源`表示。
- 条件关键字:是指云产品自身定义的条件关键字。
- 关联操作:是指成功执行操作所需要的其他权限。操作者必须同时具备关联操作的权限,操作才能成功。
| 操作 | 访问级别 | 资源类型 | 条件关键字 | 关联操作 |
| --- | --- | --- | --- | --- |
| ice:QueryDNAJobList | | \\*全部资源 `*` | 无 | 无 |
## 请求参数
| 名称 | 类型 | 必填 | 描述 | 示例值 |
| --- | --- | --- | --- | --- |
| JobIds | string | 否 | 需要查询的 DNA 作业 ID 列表。一次最多建议查询 10 个,用半角逗号(,)分隔。 | 88c6ca184c0e47098a5b665e2a12\\*\\*\\*\\* |
## 返回参数
| 名称 | 类型 | 描述 | 示例值 |
| --- | --- | --- | --- |
| | object | | |
| RequestId | string | 请求 ID。 | 25818875-5F78-4A13-BEF6-D7393642CA58 |
| JobList | array<object> | DNA 作业信息。 | |
| DNAJob | object | DNAJob | |
| DNAResult | string | DNA 结果链接。 | http://test\\_bucket.oss-cn-shanghai.aliyuncs.com/fingerprint/video/search\\_result/5/5.txt |
| PrimaryKey | string | 唯一的视频主键,唯一性由用户保证。 | 3ca84a39a9024f19853b21be9cf9\\*\\*\\*\\* |
| DBId | string | DNA 库 Id。 | 2288c6ca184c0e47098a5b665e2a12\\*\\*\\*\\* |
| CreationTime | string | 作业创建时间。 | 2022-12-28T03:21:37Z |
| FinishTime | string | 作业完成时间。 | 2022-12-28T03:21:44Z |
| Status | string | 作业状态,可取值: - **Queuing**:排队中。 - **Analysing**:分析中。 - **Success**:成功。 - **Fail**:失败。 | Queuing |
| Message | string | 作业执行错误信息。 | "The resource operated \\\\"a887d0b\\*\\*\\*d805ef6f7f6786302\\\\" cannot be found" |
| Config | string | DNA 配置。 | {"SaveType": "save","MediaType"":"video"} |
| UserData | string | 用户自定义数据。 | testdna |
| Code | string | 作业执行错误码。 | "InvalidParameter.ResourceNotFound" |
| Input | object | 输入文件。 | |
| Type | string | 输入文件类型,取值: 1. OSS:OSS 文件地址 2. Media:媒资 ID | Media |
| Media | string | 输入文件信息,支持 OSS 地址和媒资 ID 两种。 OSS 地址规则为 1、oss://bucket/object 2、http(s)://bucket.oss-\\[regionId\\].aliyuncs.com/object 其中 bucket 为和当前项目处于同一区域的 oss bucket 名称,object 为文件路径。 | 1b1b9cd148034739af413150fded\\*\\*\\*\\* |
| Id | string | 作业 ID。 | 88c6ca184c0e47098a5b665e2a12\\*\\*\\*\\* |
DNAResult 的内容为 Array of VideoMatchInfo,其中:
**VideoMatchInfo 详情**
| 名称 | 类型 | 描述 |
| --- | --- | --- |
| PrimaryKey | String | 匹配文件唯一主键。 |
| GlobalSimilarity | Double | 整体相似度。 |
| VideoMatchSegments | Array of VideoMatchSegment | 视频/图搜视频匹配片段信息。 |
| AudioMatchSegments | Array of AudioMatchSegment | 音频搜音频匹配片段信息。 |
| TextMatchSegments | Array of TextMatchSegment | 文本搜文本匹配片段信息。 |
**VideoMatchSegment/AudioMatchSegment 详情**
| 名称 | 类型 | 描述 |
| --- | --- | --- |
| StartTime | Double | 输入视频/音频的开始时间。 |
| EndTime | Double | 输入视频/音频的结束时间。 |
| MasterStartTime | Double | 库中视频/音频的开始时间。 |
| MasterEndTime | Double | 库中视频/音频的结束时间。 |
| Similarity | Double | 匹配片段的置信度。 |
**TextMatchSegment 详情**
| 名称 | 类型 | 描述 |
| --- | --- | --- |
| Start | Double | 查询匹配片段起始时间。 |
| End | Double | 查询匹配片段结束时间。 |
| QueryText | String | 查询匹配的文本片段。 |
| MasterText | String | 底库匹配的文本片段。 |
| Similarity | Double | 匹配片段的置信度。 |
## 示例
正常返回示例
`JSON`格式
```
{
"RequestId": "25818875-5F78-4A13-BEF6-D7393642CA58",
"JobList": [
{
"DNAResult": "http://test_bucket.oss-cn-shanghai.aliyuncs.com/fingerprint/video/search_result/5/5.txt",
"PrimaryKey": "3ca84a39a9024f19853b21be9cf9****",
"DBId": "2288c6ca184c0e47098a5b665e2a12****",
"CreationTime": "2022-12-28T03:21:37Z",
"FinishTime": "2022-12-28T03:21:44Z",
"Status": "Queuing",
"Message": "The resource operated \"a887d0b***d805ef6f7f6786302\" cannot be found",
"Config": "{\"SaveType\": \"save\",\"MediaType\"\":\"video\"}",
"UserData": "testdna",
"Code": "InvalidParameter.ResourceNotFound",
"Input": {
"Type": "Media",
"Media": "1b1b9cd148034739af413150fded****"
},
"Id": "88c6ca184c0e47098a5b665e2a12****"
}
]
}
```
## 错误码
访问[错误中心]( https://api.aliyun.com/document/ICE/2020-11-09/errorCode)查看更多错误码。
## 变更历史
| 变更时间 | 变更内容概要 | 操作 |
| --- | --- | --- |
| 2023-05-25 | API 内部配置变更,不影响调用 | [查看变更详情](https://api.aliyun.com/document/ICE/2020-11-09/QueryDNAJobList?updateTime=2023-05-25#workbench-doc-change-demo) |
\ No newline at end of file
调用SubmitDNAJob提交DNA作业。
## 接口说明
- 该接口为[异步接口](https://help.aliyun.com/zh/ims/developer-reference/explanation-of-the-asynchronous-task-processing-flow),提交任务后返回任务 ID(此时任务尚未完成,任务将进入后台排队异步执行),最终结果将通过回调通知,也可通过[查询 DNA 作业列表](https://help.aliyun.com/zh/ims/developer-reference/api-ice-2020-11-09-querydnajoblist)主动查询任务状态。
- 本接口目前支持使用的地域为华北 2(北京)、华东 1(杭州)、华东 2(上海)。
- 提交文本 DNA 作业目前仅支持华东 2(上海)地域使用。
## 调试
[您可以在OpenAPI Explorer中直接运行该接口,免去您计算签名的困扰。运行成功后,OpenAPI Explorer可以自动生成SDK代码示例。](https://api.aliyun.com/api/ICE/2020-11-09/SubmitDNAJob)
[![](https://img.alicdn.com/tfs/TB16JcyXHr1gK0jSZR0XXbP8XXa-24-26.png) 调试](https://api.aliyun.com/api/ICE/2020-11-09/SubmitDNAJob)
## **授权信息**
下表是API对应的授权信息,可以在RAM权限策略语句的`Action`元素中使用,用来给RAM用户或RAM角色授予调用此API的权限。具体说明如下:
- 操作:是指具体的权限点。
- 访问级别:是指每个操作的访问级别,取值为写入(Write)、读取(Read)或列出(List)。
- 资源类型:是指操作中支持授权的资源类型。具体说明如下:
- 对于必选的资源类型,用前面加 \* 表示。
- 对于不支持资源级授权的操作,用`全部资源`表示。
- 条件关键字:是指云产品自身定义的条件关键字。
- 关联操作:是指成功执行操作所需要的其他权限。操作者必须同时具备关联操作的权限,操作才能成功。
| **操作** | **访问级别** | **资源类型** | **条件关键字** | **关联操作** |
| --- | --- | --- | --- | --- |
| ice:SubmitDNAJob | | \\*全部资源 `*` | 无 | 无 |
## 请求参数
| **名称** | **类型** | **必填** | **描述** | **示例值** |
| --- | --- | --- | --- | --- |
| Input | object | 是 | 输入 DNA 文件。 | |
| Type | string | 是 | 输入文件类型,取值: 1. OSS:OSS 文件地址 2. Media:媒资 ID | Media |
| Media | string | 是 | 输入文件信息,支持 OSS 地址和媒资 ID 两种。 OSS 地址规则为: 1、oss://bucket/object 2、http(s)://bucket.oss-\\[regionId\\].aliyuncs.com/object 其中 bucket 为和当前项目处于同一区域的 oss bucket 名称,object 为文件路径。 | 1b1b9cd148034739af413150fded\\*\\*\\*\\* |
| PipelineId | string | 否 | 管道 ID。 | 5246b8d12a62433ab77845074039\\*\\*\\*\\* |
| Config | string | 否 | DNA 配置,JSON 对象。 若填写,会覆盖模板参数。 | {"SaveType": "save","MediaType":"video"} |
| TemplateId | string | 否 | 模版 ID。 | S00000101-100060 |
| UserData | string | 否 | 用户自定义数据,最大长度 128 个字节。 | userData |
| PrimaryKey | string | 是 | 唯一的视频主键,唯一性由用户保证。 | 3ca84a39a9024f19853b21be9cf9\\*\\*\\*\\* |
| DBId | string | 是 | DNA 库 ID。如需创建 DNA 库,请参见[CreateDNADB - 创建 DNA 库](https://help.aliyun.com/zh/ims/developer-reference/api-ice-2020-11-09-creatednadb)。 | 2288c6ca184c0e47098a5b665e2a12\\*\\*\\*\\* |
DNA 配置 Config 参数包括:SaveType 和 MediaType 字段。
其中,SaveType 表示存储类型,包括:
- **nosave**: 仅搜索不入库。
- **save**: 去重入库。
- **forcesave**: 强制入库。
- **onlysave**: 仅入库不搜索。
MediaType 表示输入文件媒体类型,包括:
- **video**: 视频。
- **audio**: 音频。
- **image**: 图片。
- **text**: 长文本。
- **asr**: asr 识别文本。
## **返回参数**
| **名称** | **类型** | **描述** | **示例值** |
| --- | --- | --- | --- |
| | object | | |
| RequestId | string | 请求 ID。 | 25818875-5F78-4A13-BEF6-D7393642CA58 |
| JobId | string | 视频 DNA 作业 ID。建议您保存此 ID 便于后续调用其他相关接口时使用。 | 88c6ca184c0e47098a5b665e2\\*\\*\\*\\* |
## 示例
正常返回示例
`JSON`格式
```
{
"RequestId": "25818875-5F78-4A13-BEF6-D7393642CA58",
"JobId": "88c6ca184c0e47098a5b665e2****"
}
```
## 错误码
访问[错误中心](https://api.aliyun.com/document/ICE/2020-11-09/errorCode)查看更多错误码。
## **变更历史**
更多信息,参考[变更详情](https://api.aliyun.com/document/ICE/2020-11-09/SubmitDNAJob#workbench-doc-change-demo)
\ No newline at end of file
调用ListDNADB查询DNA库。
## 调试
[您可以在OpenAPI Explorer中直接运行该接口,免去您计算签名的困扰。运行成功后,OpenAPI Explorer可以自动生成SDK代码示例。](https://api.aliyun.com/api/ICE/2020-11-09/ListDNADB)
[![](https://img.alicdn.com/tfs/TB16JcyXHr1gK0jSZR0XXbP8XXa-24-26.png)调试](https://api.aliyun.com/api/ICE/2020-11-09/ListDNADB)
## 授权信息
下表是API对应的授权信息,可以在RAM权限策略语句的`Action`元素中使用,用来给RAM用户或RAM角色授予调用此API的权限。具体说明如下:
- 操作:是指具体的权限点。
- 访问级别:是指每个操作的访问级别,取值为写入(Write)、读取(Read)或列出(List)。
- 资源类型:是指操作中支持授权的资源类型。具体说明如下:
- 对于必选的资源类型,用前面加 \* 表示。
- 对于不支持资源级授权的操作,用`全部资源`表示。
- 条件关键字:是指云产品自身定义的条件关键字。
- 关联操作:是指成功执行操作所需要的其他权限。操作者必须同时具备关联操作的权限,操作才能成功。
| 操作 | 访问级别 | 资源类型 | 条件关键字 | 关联操作 |
| --- | --- | --- | --- | --- |
| ice:ListDNADB | none | \\*全部资源 `*` | 无 | 无 |
## 请求参数
| 名称 | 类型 | 必填 | 描述 | 示例值 |
| --- | --- | --- | --- | --- |
| DBIds | string | 否 | DNA 库 ID 列表。⼀次建议最多查 10 个,ID 之间⽤半角逗号(,)分隔。 | 2288c6ca184c0e47098a5b665e2a12\\*\\*\\*\\*,78dc866518b843259669df58ed30\\*\\*\\*\\* |
## 返回参数
| 名称 | 类型 | 描述 | 示例值 |
| --- | --- | --- | --- |
| | object | | |
| DBList | array<object> | DNA 库列表。 | |
| DBInfo | object | DBInfo | |
| Status | string | DNA 库状态。默认值:**offline**(离线)。**active** 表示 DNA 库可用。可取值: - **offline**:离线。 - **active**:在线。 - **deleted**:删除。 | active |
| Description | string | DNA 库描述。 | 这是一个视频DNA库。 |
| Name | string | DNA 库名称。 | example-name |
| Model | string | DNA 库模型。包含: - **Video**:视频 - **Audio**:音频 - **Image**:图片 - **Text**:文本【仅上海区域支持】 | Video |
| DBId | string | DNA 库 Id。 | 88c6ca184c0e47098a5b665e2a12\\*\\*\\*\\* |
| RequestId | string | 请求 ID。 | 25818875-5F78-4A13-BEF6-D7393642CA58 |
## 示例
正常返回示例
`JSON`格式
```
{
"DBList": [
{
"Status": "active",
"Description": "这是一个视频DNA库。",
"Name": "example-name",
"Model": "Video",
"DBId": "88c6ca184c0e47098a5b665e2a12****"
}
],
"RequestId": "25818875-5F78-4A13-BEF6-D7393642CA58"
}
```
## 错误码
访问[错误中心]( https://api.aliyun.com/document/ICE/2020-11-09/errorCode)查看更多错误码。
## 变更历史
| 变更时间 | 变更内容概要 | 操作 |
| --- | --- | --- |
| 2024-07-23 | OpenAPI 返回结构发生变更 | [查看变更详情](https://api.aliyun.com/document/ICE/2020-11-09/ListDNADB?updateTime=2024-07-23#workbench-doc-change-demo) |
| 2023-05-25 | API 内部配置变更,不影响调用 | [查看变更详情](https://api.aliyun.com/document/ICE/2020-11-09/ListDNADB?updateTime=2023-05-25#workbench-doc-change-demo) |
\ No newline at end of file
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""查询阿里云 DNA 作业状态。
用法:
# 查询指定 JobId
python scripts/aliyun_dna/query_dna_jobs.py --job-ids job_id_1,job_id_2
# 查看最近入库状态(从 state-file 读取 JobId)
python scripts/aliyun_dna/query_dna_jobs.py --state-file upload_dna_state.json
# 只显示失败的作业
python scripts/aliyun_dna/query_dna_jobs.py --state-file upload_dna_state.json --filter failed
"""
import argparse
import json
import logging
import os
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent))
from dotenv import load_dotenv
load_dotenv(Path(__file__).resolve().parent.parent.parent / ".env")
from alibabacloud_ice20201109.client import Client as ICEClient
from alibabacloud_tea_openapi.models import Config as OpenApiConfig
from alibabacloud_ice20201109 import models as ice_models
logger = logging.getLogger(__name__)
STATUS_EMOJI = {
"Success": "✅",
"Fail": "❌",
"Queuing": "⏳",
"Analysing": "🔄",
}
def _get_ice_client() -> ICEClient:
access_key_id = os.environ["ALIYUN_ICE_ACCESS_KEY_ID"]
access_key_secret = os.environ["ALIYUN_ICE_ACCESS_KEY_SECRET"]
region = os.environ.get("ALIYUN_ICE_REGION", "cn-hangzhou")
config = OpenApiConfig(
access_key_id=access_key_id,
access_key_secret=access_key_secret,
endpoint=f"ice.{region}.aliyuncs.com",
region_id=region,
)
return ICEClient(config)
def load_jobs_from_state(state_file: str) -> dict[str, str]:
"""从 state-file 提取 job_id 映射 {audio_path: job_id}。"""
if not Path(state_file).exists():
logger.error("状态文件不存在: %s", state_file)
return {}
with open(state_file, encoding="utf-8") as f:
state = json.load(f)
return {k: v for k, v in state.items() if k.endswith("::job_id")}
def query_jobs(ice_client: ICEClient, job_ids: list[str]) -> list[dict]:
"""批量查询 DNA 作业状态。"""
request = ice_models.QueryDNAJobListRequest(job_ids=",".join(job_ids))
response = ice_client.query_dnajob_list(request)
results = []
for job in response.body.job_list or []:
results.append({
"job_id": job.id,
"status": job.status,
"primary_key": job.primary_key,
"creation_time": job.creation_time,
"finish_time": job.finish_time,
"message": job.message or "",
"dna_result_url": job.dnaresult or "",
"config": job.config or "",
})
return results
def main() -> None:
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
)
parser = argparse.ArgumentParser(description="查询阿里云 DNA 作业状态")
parser.add_argument("--job-ids", help="JobId 列表,逗号分隔")
parser.add_argument("--state-file", help="从上传状态文件中提取 JobId")
parser.add_argument("--filter", choices=["all", "success", "failed", "pending"],
default="all", help="过滤作业状态")
args = parser.parse_args()
# 验证配置
required_env = ["ALIYUN_ICE_ACCESS_KEY_ID", "ALIYUN_ICE_ACCESS_KEY_SECRET", "ALIYUN_DNA_DB_ID"]
for key in required_env:
if not os.environ.get(key):
logger.error("缺少环境变量: %s", key)
sys.exit(1)
# 收集 JobId
job_ids = []
if args.job_ids:
job_ids = [j.strip() for j in args.job_ids.split(",") if j.strip()]
elif args.state_file:
state_jobs = load_jobs_from_state(args.state_file)
job_ids = list(state_jobs.values())
logger.info("从状态文件提取到 %d 个 JobId", len(job_ids))
else:
logger.error("请指定 --job-ids 或 --state-file")
sys.exit(1)
if not job_ids:
logger.info("没有找到 JobId")
return
# 分批查询(每次最多 10 个)
ice_client = _get_ice_client()
all_results = []
for batch_start in range(0, len(job_ids), 10):
batch = job_ids[batch_start:batch_start + 10]
results = query_jobs(ice_client, batch)
all_results.extend(results)
# 过滤
if args.filter == "success":
all_results = [r for r in all_results if r["status"] == "Success"]
elif args.filter == "failed":
all_results = [r for r in all_results if r["status"] == "Fail"]
elif args.filter == "pending":
all_results = [r for r in all_results if r["status"] in ("Queuing", "Analysing")]
# 输出
status_count = {}
for r in all_results:
s = r["status"]
status_count[s] = status_count.get(s, 0) + 1
print(f"\n作业状态汇总 (共 {len(all_results)} 个):")
for status, count in sorted(status_count.items()):
emoji = STATUS_EMOJI.get(status, "❓")
print(f" {emoji} {status}: {count}")
print(f"\n{'JobId':<40} {'状态':<12} {'PrimaryKey':<20} {'创建时间':<22} {'错误信息'}")
print("-" * 140)
for r in sorted(all_results, key=lambda x: x.get("creation_time", "")):
emoji = STATUS_EMOJI.get(r["status"], "❓")
print(f"{emoji} {r['job_id']:<38} {r['status']:<12} {r['primary_key']:<20} "
f"{r['creation_time'] or 'N/A':<22} {r['message']}")
# 汇总输出
print(f"\n总计: {len(all_results)} 个作业")
if __name__ == "__main__":
main()
......@@ -6,10 +6,7 @@ expected_song_id 的 top-k/top1 命中只作为诊断字段。
输出 precision/recall/F1。
用法:
python scripts/evaluate_composition.py \
--dsn "postgresql:///lyric_dedup" \
--queries composition_testset/test_samples.csv \
--out composition_dedup/composition_eval/nohop_result.csv
"""
import argparse
......@@ -20,10 +17,10 @@ import sys
import time
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent))
from dotenv import load_dotenv
load_dotenv(Path(__file__).resolve().parent.parent / ".env")
load_dotenv(Path(__file__).resolve().parent.parent.parent / ".env")
from composition_dedup.service import CompositionConfig, CompositionDedupService
......
......@@ -8,9 +8,9 @@
python scripts/generate_composition_testset.py \
--audio-dir /Volumes/移动硬盘/composition_test \
--negative-audio-dir /Volumes/移动硬盘/composition_drop \
--out-dir composition_testset \
--num-songs 100 \
--num-negative-songs 100 \
--out-dir composition_testset_20 \
--num-songs 20 \
--num-negative-songs 20 \
--negative-variants \
--seed 123
......@@ -58,10 +58,12 @@ logger = logging.getLogger(__name__)
# --------------------------------------------------------------------------
DSP_VARIANTS: list[tuple[str, str]] = [
# Pitch Shift(±1、±2 半音)
("pitch_up1", "asetrate=22050*1.0595,aresample=22050"), # +1 半音
("pitch_up2", "asetrate=22050*1.1225,aresample=22050"), # +2 半音
("pitch_down1", "asetrate=22050*0.9439,aresample=22050"), # -1 半音
("pitch_down2", "asetrate=22050*0.8909,aresample=22050"), # -2 半音
# aresample=22050 先把源文件归一化到 22050Hz,再用 asetrate 计算正确的变调偏移,
# 避免源文件为 44100/48000Hz 等非 22050Hz 时 asetrate 基准算错导致时长异常。
("pitch_up1", "aresample=22050,asetrate=22050*1.0595,aresample=22050"), # +1 半音
("pitch_up2", "aresample=22050,asetrate=22050*1.1225,aresample=22050"), # +2 半音
("pitch_down1", "aresample=22050,asetrate=22050*0.9439,aresample=22050"), # -1 半音
("pitch_down2", "aresample=22050,asetrate=22050*0.8909,aresample=22050"), # -2 半音
# Tempo Shift
("tempo_slow", "atempo=0.90"), # 0.9x
("tempo_fast", "atempo=1.10"), # 1.1x
......@@ -84,7 +86,7 @@ HARD_POSITIVE_VARIANTS: list[tuple[str, str]] = [
# 只保留副歌:截取中间 40%(模拟短视频截段)
("chorus_only", None), # 特殊处理,用 -ss + -t 参数
# Pitch + Tempo 叠加(模拟 Live 版同时有调整)
("pitch_up1_tempo_slow", "asetrate=22050*1.0595,aresample=22050,atempo=0.92"),
("pitch_up1_tempo_slow", "aresample=22050,asetrate=22050*1.0595,aresample=22050,atempo=0.92"),
]
# 负样本变体只使用相对温和的处理,避免把负样本评估变成极端音质测试。
......@@ -93,6 +95,10 @@ NEGATIVE_VARIANTS: list[tuple[str, str | None]] = [
("negative_codec_128k", "acodec=libmp3lame,b:a=128k"),
]
# 片段拼接负样本:每个样本从几首不同歌曲各取一段拼接,测试系统对多曲混合音频的误报率。
SPLICE_SONGS_PER_SAMPLE = 3 # 每个拼接样本使用的源歌曲数
SPLICE_FRAGMENT_SECS = 20.0 # 每段截取时长(秒)
def _ffmpeg_variant(src: Path, dst: Path, af: str) -> bool:
"""普通 audio filter 变换。"""
......@@ -126,6 +132,43 @@ def _ffmpeg_variant(src: Path, dst: Path, af: str) -> bool:
return _run_ffmpeg(cmd)
def _ffmpeg_splice(sources: list[Path], dst: Path, fragment_secs: float = SPLICE_FRAGMENT_SECS) -> bool:
"""从多首歌曲各取一段片段,拼接为单个音频文件。
每段从歌曲 20%~60% 处随机取起点,避开开头和结尾。
使用 filter_complex concat 在一次 ffmpeg 调用内完成,无临时文件。
"""
inputs: list[str] = []
filter_parts: list[str] = []
for i, src in enumerate(sources):
duration = _probe_duration(src)
if duration is None:
return False
usable_end = max(0.0, duration - fragment_secs)
start_min = min(duration * 0.20, usable_end)
start_max = min(duration * 0.60, usable_end)
ss = random.uniform(start_min, start_max) if start_max > start_min else start_min
inputs += ["-i", str(src)]
filter_parts.append(
f"[{i}]atrim=start={ss:.3f}:duration={fragment_secs:.3f},aresample=22050[seg{i}]"
)
n = len(sources)
concat_inputs = "".join(f"[seg{i}]" for i in range(n))
filter_parts.append(f"{concat_inputs}concat=n={n}:v=0:a=1[out]")
cmd = [
"ffmpeg", "-y",
*inputs,
"-filter_complex", ";".join(filter_parts),
"-map", "[out]",
"-ar", "22050", "-ac", "1",
str(dst),
]
return _run_ffmpeg(cmd)
def _ffmpeg_trim(src: Path, dst: Path, start_ratio: float, duration_ratio: float) -> bool:
"""按相对位置截取片段。需要先探测时长。"""
duration = _probe_duration(src)
......@@ -311,6 +354,25 @@ def main() -> None:
"expected": "not_duplicate",
})
# 片段拼接负样本:数量与其他负样本变体保持一致
if args.negative_variants and len(negative_selected) >= SPLICE_SONGS_PER_SAMPLE:
for i in _tqdm(range(len(negative_selected)), desc="生成片段拼接负样本", total=len(negative_selected)):
srcs = random.sample(negative_selected, SPLICE_SONGS_PER_SAMPLE)
splice_id = f"splice_{i:04d}"
dst = variants_dir / f"{splice_id}_negative_splice.wav"
ok = _ffmpeg_splice(srcs, dst)
if not ok:
logger.warning("片段拼接生成失败,跳过: splice %d", i)
continue
query_rows.append({
"song_id": splice_id,
"audio_path": str(dst),
"variant": "negative_splice",
"sample_class": "negative",
"expected_song_id": "",
"expected": "not_duplicate",
})
ref_path = out_dir / "reference.csv"
query_path = out_dir / "queries.csv"
......
......@@ -7,6 +7,12 @@ python scripts/import_audio_composition.py \
--ext .wav
支持通过 --file-list 指定一个包含音频路径的文本文件(每行一个路径)。
--update-chroma-full 模式:
仅为 chroma_full 列为 NULL 的已入库歌曲补写固定帧率 Chromagram,
跳过 feature_vector 重提取和 Dejavu 指纹计算,速度更快。
适用于开启子序列 DTW(COMPOSITION_SUBSEQUENCE_DTW_ENABLED=true)后的
首次全量补全,无需清空重建。
"""
import argparse
......@@ -14,10 +20,10 @@ import logging
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent))
from dotenv import load_dotenv
load_dotenv(Path(__file__).resolve().parent.parent / ".env")
load_dotenv(Path(__file__).resolve().parent.parent.parent / ".env")
from tqdm import tqdm
......@@ -79,6 +85,13 @@ def main() -> None:
parser.add_argument("--ext", default=".wav", help="音频文件扩展名(默认 .wav)")
parser.add_argument("--batch-size", type=int, default=10, help="批次大小(默认 10)")
parser.add_argument("--clear", action="store_true", help="导入前清空 composition_feature 和 dejavu_fingerprints 表数据(保留表结构)")
parser.add_argument(
"--update-chroma-full",
action="store_true",
help="仅为 chroma_full=NULL 的已入库歌曲补写固定帧率 Chromagram,"
"跳过 feature_vector 重提取和 Dejavu 指纹,速度更快。"
"需先执行 migrate_add_chroma_full.sql 并确认 COMPOSITION_SUBSEQUENCE_DTW_ENABLED=true。",
)
args = parser.parse_args()
config = CompositionConfig(dsn=args.dsn)
......@@ -95,6 +108,10 @@ def main() -> None:
audio_files = discover_audio_files(args.audio_dir, args.file_list, args.ext)
logger.info("发现 %d 个音频文件", len(audio_files))
if args.update_chroma_full:
_update_chroma_full(config, audio_files)
return
success_count = 0
fail_count = 0
......@@ -111,5 +128,64 @@ def main() -> None:
logger.info("导入完成: 成功 %d, 失败 %d", success_count, fail_count)
def _update_chroma_full(config: "CompositionConfig", audio_files: list[tuple[str, str]]) -> None:
"""仅补写 chroma_full 列,跳过 feature_vector 和 Dejavu 计算。"""
import psycopg
from composition_dedup.extractor import (
TARGET_SR,
extract_chroma_fixed_fps_from_samples,
load_audio_mono_22050hz,
)
# 查询库中 chroma_full 为 NULL 的 song_id
with psycopg.connect(config.dsn) as conn:
with conn.cursor() as cur:
cur.execute("SELECT song_id FROM composition_feature WHERE chroma_full IS NULL")
missing_ids = {str(row[0]) for row in cur.fetchall()}
if not missing_ids:
logger.info("所有已入库歌曲均已有 chroma_full,无需补全")
return
# 过滤:只处理缺少 chroma_full 的歌曲
to_update = [(sid, path) for sid, path in audio_files if sid in missing_ids]
logger.info(
"库中 chroma_full=NULL 的歌曲: %d 首,匹配到本地音频: %d 首(未匹配: %d 首)",
len(missing_ids),
len(to_update),
len(missing_ids) - len(to_update),
)
success_count = 0
fail_count = 0
for song_id, audio_path in tqdm(to_update, desc="补全 chroma_full"):
try:
samples = load_audio_mono_22050hz(audio_path)
chroma_full = extract_chroma_fixed_fps_from_samples(
samples, TARGET_SR,
target_fps=config.chroma_full_fps,
hop_length=config.chroma_hop_length,
win_len_smooth=config.chroma_win_len_smooth,
)
with psycopg.connect(config.dsn) as conn:
with conn.cursor() as cur:
cur.execute(
"""
UPDATE composition_feature
SET chroma_full = %s, chroma_n_frames = %s
WHERE song_id = %s
""",
(chroma_full.flatten().tolist(), int(chroma_full.shape[1]), int(song_id)),
)
conn.commit()
success_count += 1
except Exception as e:
logger.error("补全失败: song_id=%s, path=%s, error=%s", song_id, audio_path, e)
fail_count += 1
logger.info("chroma_full 补全完成: 成功 %d, 失败 %d", success_count, fail_count)
if __name__ == "__main__":
main()
......
-- 为 composition_feature 表新增 audio_url 字段,用于 WebUI 预览播放
-- 已有记录保持 NULL(无 URL 则 WebUI 不显示播放器)
alter table composition_feature
add column if not exists audio_url text;
-- 迁移:为 composition_feature 表添加子序列 DTW 所需的列
--
-- 适用场景:已有运行中的数据库,需要升级以支持 COMPOSITION_SUBSEQUENCE_DTW_ENABLED=true。
--
-- 执行方式:
-- psql postgresql:///lyric_dedup -f scripts/migrate_add_chroma_full.sql
--
-- 执行后还需对已入库歌曲重新跑 ingest(chroma_full 列为 NULL 的行在查询时
-- 会自动 fallback 到 feature_vector,精排精度与旧版本相同,不影响正常使用)。
ALTER TABLE composition_feature
ADD COLUMN IF NOT EXISTS chroma_full float4[],
ADD COLUMN IF NOT EXISTS chroma_n_frames integer;
......@@ -47,6 +47,10 @@ create table if not exists composition_feature (
id bigserial primary key,
song_id bigint not null unique,
feature_vector vector(1536) not null,
-- 子序列 DTW:固定帧率 Chromagram(帧数随曲长变化,由 COMPOSITION_CHROMA_FULL_FPS 决定)
-- 仅在 COMPOSITION_SUBSEQUENCE_DTW_ENABLED=true 时由 ingest 写入
chroma_full float4[],
chroma_n_frames integer,
created_at timestamptz not null default now()
);
......
"""歌词去重 API 测试脚本
"""歌词去重 & 曲去重 API 测试脚本
用法:
# ---- 歌词去重 ----
# 上传指定歌词文件并调用去重 API
python test_api/test_dedup_api.py --file data/library/None_WHHY134166.lrc
......@@ -15,6 +16,14 @@
# 指定 API 地址
python test_api/test_dedup_api.py --file data/library/None_WHHY134166.lrc --api-url "http://localhost:8000"
# ---- 曲去重:入库 ----
python test_api/test_dedup_api.py rhythm-ingest --song-id 12345 --file data/audio/song.mp3
python test_api/test_dedup_api.py rhythm-ingest --song-id 12345 --url "https://example.com/song.mp3"
# ---- 曲去重:查询 ----
python test_api/test_dedup_api.py rhythm-check --file data/audio/query.mp3
python test_api/test_dedup_api.py rhythm-check --url "https://example.com/query.mp3" --top-k 50
"""
import argparse
import json
......@@ -43,6 +52,16 @@ def upload_lyric_file(file_path: str) -> str:
return result
def upload_audio_file(file_path: str) -> str:
"""上传音频文件到 OSS,返回公开 URL"""
uploader = OSSUploader()
success, result = uploader.upload_file(file_path)
if not success:
print(f"上传失败: {result}")
sys.exit(1)
return result
def call_dedup_api(url: str, title: str | None, artist: str | None, api_base: str) -> dict:
"""调用去重 API"""
payload = json.dumps({
......@@ -73,17 +92,100 @@ def call_dedup_api(url: str, title: str | None, artist: str | None, api_base: st
def main():
parser = argparse.ArgumentParser(description="歌词去重 API 测试")
parser = argparse.ArgumentParser(description="去重 API 测试")
parser.add_argument("--api-url", default="http://localhost:8000", help="API 服务地址 (默认 http://localhost:8000)")
subparsers = parser.add_subparsers(dest="command")
# ---- 曲去重:入库 ----
ingest_parser = subparsers.add_parser("rhythm-ingest", help="将音频特征写入曲库")
ingest_parser.add_argument("--song-id", "-s", type=int, required=True, help="歌曲 ID")
ingest_parser.add_argument("--file", "-f", help="本地音频文件路径")
ingest_parser.add_argument("--url", "-u", help="已上传的音频 URL(跳过上传步骤)")
# ---- 曲去重:查询 ----
check_parser = subparsers.add_parser("rhythm-check", help="查询音频是否与曲库重复")
check_parser.add_argument("--file", "-f", help="本地音频文件路径")
check_parser.add_argument("--url", "-u", help="已上传的音频 URL(跳过上传步骤)")
check_parser.add_argument("--top-k", type=int, default=100, help="召回候选数量(默认 100)")
# ---- 歌词去重(默认,无子命令时走此路径) ----
parser.add_argument("--file", "-f", help="本地歌词文件路径")
parser.add_argument("--url", "-u", help="已上传的歌词 URL(跳过上传步骤)")
parser.add_argument("--title", "-t", help="歌曲标题(可选)")
parser.add_argument("--artist", "-a", help="歌手名(可选)")
parser.add_argument("--api-url", default="http://localhost:8000", help="API 服务地址 (默认 http://localhost:8000)")
parser.add_argument("--upload-only", action="store_true", help="仅上传到 OSS,不调用 API")
args = parser.parse_args()
# ------------------------------------------------------------------ #
# 曲去重:入库
# ------------------------------------------------------------------ #
if args.command == "rhythm-ingest":
if not args.file and not args.url:
ingest_parser.error("需要指定 --file 或 --url")
if args.file:
abs_path = os.path.join(PROJECT_ROOT, args.file) if not os.path.isabs(args.file) else args.file
if not os.path.exists(abs_path):
print(f"文件不存在: {abs_path}")
sys.exit(1)
print(f"正在上传音频: {abs_path}")
audio_url = upload_audio_file(abs_path)
print(f"上传成功: {audio_url}")
else:
audio_url = args.url
print(f"使用已有 URL: {audio_url}")
print(f"\n正在调用曲入库 API (song_id={args.song_id})...")
result = _call_rhythm_ingest(audio_url, args.song_id, api_base=args.api_url)
print(f"\n结果:")
print(f" song_id: {result.get('song_id')}")
print(f" success: {result.get('success')}")
print(f" message: {result.get('message', '')}")
return
# ------------------------------------------------------------------ #
# 曲去重:查询
# ------------------------------------------------------------------ #
if args.command == "rhythm-check":
if not args.file and not args.url:
check_parser.error("需要指定 --file 或 --url")
if args.file:
abs_path = os.path.join(PROJECT_ROOT, args.file) if not os.path.isabs(args.file) else args.file
if not os.path.exists(abs_path):
print(f"文件不存在: {abs_path}")
sys.exit(1)
print(f"正在上传音频: {abs_path}")
audio_url = upload_audio_file(abs_path)
print(f"上传成功: {audio_url}")
else:
audio_url = args.url
print(f"使用已有 URL: {audio_url}")
print(f"\n正在调用曲去重 API...")
result = _call_rhythm_check(audio_url, top_k=args.top_k, api_base=args.api_url)
print(f"\n结果:")
print(f" duplicate: {result.get('duplicate')}")
candidates = result.get("candidates", [])
if candidates:
print(f" candidates ({len(candidates)}):")
for i, c in enumerate(candidates[:10]):
print(f" [{i+1}] song_id={c.get('song_id')} similarity={c.get('similarity', 0):.4f} source={c.get('source')}")
if len(candidates) > 10:
print(f" ... 共 {len(candidates)} 条")
else:
print(f" candidates: []")
return
# ------------------------------------------------------------------ #
# 歌词去重(默认路径,无子命令)
# ------------------------------------------------------------------ #
if not args.file and not args.url:
parser.error("需要指定 --file 或 --url")
parser.error("需要指定 --file 或 --url,或使用子命令 rhythm-ingest / rhythm-check")
# Step 1: 上传
if args.file:
......@@ -113,5 +215,49 @@ def main():
print(f" record_ids: {result.get('record_ids', [])}")
def _call_rhythm_ingest(url: str, song_id: int, api_base: str) -> dict:
"""调用曲入库 API"""
payload = json.dumps({"song_id": song_id, "url": url}).encode("utf-8")
req = urllib.request.Request(
f"{api_base.rstrip('/')}/api/v1/rhythm/ingest",
data=payload,
headers={"Content-Type": "application/json"},
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=120) as resp:
return json.loads(resp.read().decode("utf-8"))
except urllib.error.HTTPError as exc:
error_body = exc.read().decode("utf-8", errors="replace")
print(f"API 请求失败 (HTTP {exc.code}): {error_body}")
sys.exit(1)
except urllib.error.URLError as exc:
print(f"API 请求失败: {exc.reason}")
print("请确认 API 服务已启动: uvicorn lyric_dedup_server.app:app --host 0.0.0.0 --port 8000")
sys.exit(1)
def _call_rhythm_check(url: str, top_k: int, api_base: str) -> dict:
"""调用曲去重查询 API"""
payload = json.dumps({"url": url, "top_k": top_k}).encode("utf-8")
req = urllib.request.Request(
f"{api_base.rstrip('/')}/api/v1/rhythm/check",
data=payload,
headers={"Content-Type": "application/json"},
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=120) as resp:
return json.loads(resp.read().decode("utf-8"))
except urllib.error.HTTPError as exc:
error_body = exc.read().decode("utf-8", errors="replace")
print(f"API 请求失败 (HTTP {exc.code}): {error_body}")
sys.exit(1)
except urllib.error.URLError as exc:
print(f"API 请求失败: {exc.reason}")
print("请确认 API 服务已启动: uvicorn lyric_dedup_server.app:app --host 0.0.0.0 --port 8000")
sys.exit(1)
if __name__ == "__main__":
main()
......
{
"/Volumes/移动硬盘/composition_test/202930109_GA000376.wav": "ok",
"/Volumes/移动硬盘/composition_test/203334926_DL002070.wav": "ok",
"/Volumes/移动硬盘/composition_test/203450368_GA000544.wav": "ok",
"/Volumes/移动硬盘/composition_test/203461409_DL001808.wav": "ok",
"/Volumes/移动硬盘/composition_test/203599405_DL001021.wav": "ok",
"/Volumes/移动硬盘/composition_test/203642418_GA001691.wav": "ok",
"/Volumes/移动硬盘/composition_test/203648006_GA000003.wav": "ok",
"/Volumes/移动硬盘/composition_test/203676351_GA000200.wav": "ok",
"/Volumes/移动硬盘/composition_test/203764838_DL002119.wav": "ok",
"/Volumes/移动硬盘/composition_test/203795984_GA000828.wav": "ok",
"/Volumes/移动硬盘/composition_test/203844120_GA000067.wav": "ok",
"/Volumes/移动硬盘/composition_test/203866845_DL002604.wav": "ok",
"/Volumes/移动硬盘/composition_test/203897230_GA001181.wav": "ok",
"/Volumes/移动硬盘/composition_test/203899643_GA000559.wav": "ok",
"/Volumes/移动硬盘/composition_test/204120902_DL002115.wav": "ok",
"/Volumes/移动硬盘/composition_test/204194303_DL002166.wav": "ok",
"/Volumes/移动硬盘/composition_test/204194460_DL001888.wav": "ok",
"/Volumes/移动硬盘/composition_test/204194494_DL001414.wav": "ok",
"/Volumes/移动硬盘/composition_test/204194543_DL001654.wav": "ok",
"/Volumes/移动硬盘/composition_test/204194891_DL001805.wav": "ok",
"/Volumes/移动硬盘/composition_test/204194998_DL001069.wav": "ok",
"/Volumes/移动硬盘/composition_test/204195062_DL001685.wav": "ok",
"/Volumes/移动硬盘/composition_test/204195065_DL001686.wav": "ok",
"/Volumes/移动硬盘/composition_test/204195067_DL002215.wav": "ok",
"/Volumes/移动硬盘/composition_test/204229926_DL001993.wav": "ok",
"/Volumes/移动硬盘/composition_test/204425947_GA001690.wav": "ok",
"/Volumes/移动硬盘/composition_test/204444119_DL001775.wav": "ok",
"/Volumes/移动硬盘/composition_test/204457018_DL002107.wav": "ok",
"/Volumes/移动硬盘/composition_test/204457023_DL002183.wav": "ok",
"/Volumes/移动硬盘/composition_test/204540829_GA000177.wav": "ok",
"/Volumes/移动硬盘/composition_test/204540918_GA000071.wav": "ok",
"/Volumes/移动硬盘/composition_test/204541109_DL001970.wav": "ok",
"/Volumes/移动硬盘/composition_test/204586870_DL000981.wav": "ok",
"/Volumes/移动硬盘/composition_test/205000888_GA001717.wav": "ok",
"/Volumes/移动硬盘/composition_test/205006910_DL000850.wav": "ok",
"/Volumes/移动硬盘/composition_test/206058550_DL000985.wav": "ok",
"/Volumes/移动硬盘/composition_test/206349056_DL002167.wav": "ok",
"/Volumes/移动硬盘/composition_test/206349945_DL002113.wav": "ok",
"/Volumes/移动硬盘/composition_test/206711872_DL001965.wav": "ok",
"/Volumes/移动硬盘/composition_test/206974764_DL000931.wav": "ok",
"/Volumes/移动硬盘/composition_test/206974765_DL000932.wav": "ok",
"/Volumes/移动硬盘/composition_test/206974766_DL000933.wav": "ok",
"/Volumes/移动硬盘/composition_test/206974775_DL001893.wav": "ok",
"/Volumes/移动硬盘/composition_test/206974779_DL000934.wav": "ok",
"/Volumes/移动硬盘/composition_test/206976058_DL001408.wav": "ok",
"/Volumes/移动硬盘/composition_test/209166597_GA001386.wav": "ok",
"/Volumes/移动硬盘/composition_test/209167720_GA001358.wav": "ok",
"/Volumes/移动硬盘/composition_test/209361104_DL001611.wav": "ok",
"/Volumes/移动硬盘/composition_test/210349676_DL002228.wav": "ok",
"/Volumes/移动硬盘/composition_test/210351016_DL001954.wav": "ok",
"/Volumes/移动硬盘/composition_test/210352134_DL001953.wav": "ok",
"/Volumes/移动硬盘/composition_test/210985464_DL001709.wav": "ok",
"/Volumes/移动硬盘/composition_test/210985511_DL001894.wav": "ok",
"/Volumes/移动硬盘/composition_test/210985512_DL001896.wav": "ok",
"/Volumes/移动硬盘/composition_test/210985513_DL000935.wav": "ok",
"/Volumes/移动硬盘/composition_test/210985514_DL001895.wav": "ok",
"/Volumes/移动硬盘/composition_test/210985515_DL000936.wav": "ok",
"/Volumes/移动硬盘/composition_test/210985525_DL002226.wav": "ok",
"/Volumes/移动硬盘/composition_test/210985649_DL000926.wav": "ok",
"/Volumes/移动硬盘/composition_test/211885778_DL001891.wav": "ok",
"/Volumes/移动硬盘/composition_test/211925190_DL001085.wav": "ok",
"/Volumes/移动硬盘/composition_test/211932514_DL001320.wav": "ok",
"/Volumes/移动硬盘/composition_test/212019324_GA000768.wav": "ok",
"/Volumes/移动硬盘/composition_test/212030195_GA000576.wav": "ok",
"/Volumes/移动硬盘/composition_test/212034152_DL001024.wav": "ok",
"/Volumes/移动硬盘/composition_test/212037852_DL000851.wav": "ok",
"/Volumes/移动硬盘/composition_test/212071825_DL000432.wav": "ok",
"/Volumes/移动硬盘/composition_test/212081995_DL001359.wav": "ok",
"/Volumes/移动硬盘/composition_test/212081996_DL001357.wav": "ok",
"/Volumes/移动硬盘/composition_test/212081997_DL001358.wav": "ok",
"/Volumes/移动硬盘/composition_test/212081998_DL001361.wav": "ok",
"/Volumes/移动硬盘/composition_test/212081999_DL001360.wav": "ok",
"/Volumes/移动硬盘/composition_test/212085899_DL001086.wav": "ok",
"/Volumes/移动硬盘/composition_test/212440713_GA000045.wav": "ok",
"/Volumes/移动硬盘/composition_test/212447042_DL000848.wav": "ok",
"/Volumes/移动硬盘/composition_test/212447124_DL001103.wav": "ok",
"/Volumes/移动硬盘/composition_test/212597558_DL001581.wav": "ok",
"/Volumes/移动硬盘/composition_test/212611861_DL002099.wav": "ok",
"/Volumes/移动硬盘/composition_test/212612210_DL002727.wav": "ok",
"/Volumes/移动硬盘/composition_test/212612932_GA001913.wav": "ok",
"/Volumes/移动硬盘/composition_test/212665934_GA000729.wav": "ok",
"/Volumes/移动硬盘/composition_test/212665935_GA000798.wav": "ok",
"/Volumes/移动硬盘/composition_test/212668644_DL002087.wav": "ok",
"/Volumes/移动硬盘/composition_test/212668672_DL001671.wav": "ok",
"/Volumes/移动硬盘/composition_test/212668706_DL001717.wav": "ok",
"/Volumes/移动硬盘/composition_test/212668732_DL002728.wav": "ok",
"/Volumes/移动硬盘/composition_test/212668737_DL002100.wav": "ok",
"/Volumes/移动硬盘/composition_test/212675255_GA000082.wav": "ok",
"/Volumes/移动硬盘/composition_test/212675315_GA002000.wav": "ok",
"/Volumes/移动硬盘/composition_test/212675363_GA000180.wav": "ok",
"/Volumes/移动硬盘/composition_test/212675414_DL001964.wav": "ok",
"/Volumes/移动硬盘/composition_test/212676914_GA000530.wav": "ok",
"/Volumes/移动硬盘/composition_test/212736695_DL001963.wav": "ok",
"/Volumes/移动硬盘/composition_test/212782387_GA000884.wav": "ok",
"/Volumes/移动硬盘/composition_test/212782388_GA000885.wav": "ok",
"/Volumes/移动硬盘/composition_test/212782389_GA000886.wav": "ok",
"/Volumes/移动硬盘/composition_test/212782390_GA000887.wav": "ok",
"/Volumes/移动硬盘/composition_test/212782391_GA000888.wav": "ok",
"/Volumes/移动硬盘/composition_test/212784198_GA001530.wav": "ok",
"/Volumes/移动硬盘/composition_test/212814258_DL002106.wav": "ok"
}
\ No newline at end of file
# 第一类:数字信号变换样本(必须有)
这是你目前已经在做的。
## Pitch Shift
```
原曲
+1半音
+2半音
-1半音
-2半音
```
验证:
```
转调鲁棒性
```
------
## Tempo Shift
```
0.9x
1.1x
1.2x
```
验证:
```
速度鲁棒性
```
------
## EQ变换
```
低通
高通
均衡器
```
验证:
```
音色鲁棒性
```
------
## 压缩编码
```
wav
320k mp3
128k mp3
aac
```
验证:
```
编码鲁棒性
```
------
# 第二类:真实版本样本(最重要)
这一类比DSP测试重要得多。
------
## 原唱 vs 伴奏版
例如:
```
晴天原唱
晴天伴奏
```
期望:
```
重复
```
------
## 原唱 vs Live版
例如:
```
晴天录音室版
晴天Live版
```
期望:
```
重复
```
------
## 原唱 vs 翻唱版
例如:
```
周杰伦版
路人翻唱版
```
期望:
```
重复
```
------
## 原唱 vs 钢琴版
例如:
```
晴天
纯钢琴演奏
```
期望:
```
重复
```
------
## 原唱 vs 交响乐版
例如:
```
晴天
交响乐改编
```
期望:
```
重复
```
------
这一类最能体现:
```
作曲结构提取能力
```
------
# 第三类:困难正样本(重点)
这是很多系统最后翻车的地方。
------
## 同曲不同调
例如:
```
原版 C调
女声版 D调
```
------
## 同曲不同速度
例如:
```
原版 120 BPM
Live版 105 BPM
```
------
## 同曲不同段落长度
例如:
```
前奏删减版
副歌重复版
```
------
## 只保留副歌
例如:
```
完整版
短视频版
```
------
这一类特别适合验证:
```
DTW
```
是否真的有效。
------
# 第四类:困难负样本(非常重要)
如果不做这一类,
```
Precision=1
```
没有意义。
------
## 同和弦进行
例如:
```
C G Am F
```
套路歌。
------
现实里很多歌:
```
和弦一样
```
但曲不同。
------
这是你们最大的风险来源。
------
## 同风格歌曲
例如:
```
周杰伦A歌
周杰伦B歌
```
------
编曲很像。
------
## 同歌手歌曲
例如:
```
Taylor Swift A
Taylor Swift B
```
------
音色很像。
------
## 同伴奏模板
例如:
```
抖音BGM A
抖音BGM B
```
------
这些非常容易误召回。
\ No newline at end of file
# 基于 Chromagram Sequence 的歌曲作曲结构去重系统 V1 实施方案
# 1 项目目标
## 1.1 项目背景
当前音乐去重体系划分为三个独立模块:
### 歌词去重
负责识别:
* 歌词重复
* 改编歌词
* 部分重复歌词
当前已采用:
* Jaccard Similarity
* PostgreSQL pg_trgm
* 规则引擎
实现文本层去重。
---
### 录音去重
负责识别:
* 同一录音
* 同源录音
* 音频转载
* 音频搬运
主要关注:
* 音色
* 演唱
* 录音环境
* 混音信息
该模块由独立团队负责。
---
### 曲去重(本项目)
本项目仅关注:
> 作曲结构(Composition)
包括:
* 音级关系
* 和声结构
* 曲式演化
* 音乐时间结构
不关注:
* 歌手
* 音色
* 乐器品牌
* 录音设备
* 混音质量
* 母带版本
---
## 1.2 项目目标
### V1 适用范围
**仅处理完整版 vs 完整版的对比。**
识别:
```text
晴天(原唱)
晴天(伴奏版)
晴天(钢琴版)
晴天(交响乐版)
晴天(翻唱版)
```
为同一作品。
区分:
```text
晴天
七里香
稻香
```
等不同作品。
### V1 不覆盖的场景
* 全曲 vs 片段(属于 V2+ 规划)
* 片段 vs 片段(属于 V2+ 规划)
---
# 2 总体技术路线
系统流程:
```text
音频
Chromagram提取
时间归一化(12×512)
Chromagram Sequence
PostgreSQL + pgvector存储
Cosine Similarity召回
重复判定
```
---
# 3 技术选型依据
## 3.1 为什么不做人声分离
人声分离主要用于:
```text
歌手分析
主唱分析
音色分析
```
而本项目关注:
```text
作曲结构
```
Chromagram本身对乐器和人声具有一定鲁棒性。
因此V1阶段不引入额外模型。
降低系统复杂度。
---
## 3.2 为什么不做和弦识别
和弦识别属于:
```text
音频
和弦推理
离散标签
```
过程。
每增加一次推理:
```text
误差增加
信息损失增加
```
跨版本研究表明,同一首歌不同录音版本的和弦识别序列一致率仅约 64%,
不同版本(乐队翻唱、失真吉他等)会引入不一致的和弦检测错误,
导致文本相似度不稳定。
而曲去重更关注:
```text
原始结构信息
```
因此直接使用Chromagram。
---
## 3.3 为什么选择Chromagram
Chromagram是音乐信息检索(MIR)领域最经典的作曲特征之一。
其核心思想:
将所有音高映射到:
```text
C
C#
D
D#
E
F
F#
G
G#
A
A#
B
```
12个音级。
因此:
```text
钢琴版
交响乐版
翻唱版
伴奏版
```
即使音色完全不同,
仍然会保留较高相似的色度结构。
---
# 4 特征提取
## 4.1 技术选型
Python
librosa
核心接口:
```python
librosa.feature.chroma_cqt()
```
---
## 4.2 音频预处理
统一格式:
```text
采样率:
22050Hz
声道:
Mono
格式:
WAV
```
转换工具:
ffmpeg
---
## 4.3 Chromagram生成
输入:
```text
audio.wav
```
输出:
```text
12 × T
```
矩阵。
示例:
```text
C
C#
D
...
B
```
对应:
```text
时间帧1
时间帧2
时间帧3
...
时间帧T
```
---
# 5 时间归一化
## 5.1 问题
不同歌曲长度不同:
```text
3分钟歌曲
5分钟歌曲
8分钟歌曲
```
得到:
```text
12×2000
12×3500
12×6000
```
无法直接比较。
---
## 5.2 解决方案
统一重采样:
```text
12 × 512
```
实现:
```python
from scipy.signal import resample
chroma_fixed = resample(
chroma,
512,
axis=1
)
```
帧数选择依据:
* 128 帧:每帧约 1.8 秒(4 分钟歌曲),时间分辨率过粗
* 512 帧:每帧约 0.47 秒,结构细节保留充分
* 512 帧展开后 6144 维,pgvector 性能可接受
---
## 5.3 设计思想
采用:
```text
相对时间轴
```
而非:
```text
绝对时间轴
```
即:
```text
0%
10%
20%
...
100%
```
描述歌曲结构。
保证:
```text
原版
钢琴版
翻唱版
```
长度不同仍可比较。
注:V1 仅处理完整版对比,Live 版等结构变体的鲁棒性在 V2 中通过 DTW 精排优化。
---
# 6 曲特征表示
最终统一表示为:
```text
12 × 512
```
矩阵。
即:
```text
6144维特征
```
展开后:
```python
feature = chroma_fixed.flatten()
```
得到:
```text
6144维向量
```
作为:
```text
Composition Fingerprint
```
---
# 7 数据库存储设计
## 7.1 表结构
```sql
CREATE TABLE composition_feature
(
id BIGSERIAL PRIMARY KEY,
song_id BIGINT NOT NULL,
feature_vector vector(6144),
created_time TIMESTAMP DEFAULT NOW()
);
```
## 7.2 字段说明
feature_vector
存储:
```text
6144维Chromagram特征(pgvector vector类型)
```
使用 pgvector 的 `vector` 类型而非 `FLOAT4[]`
直接支持 Cosine 相似度索引和检索。
---
## 7.3 索引设计
使用 pgvector:
```sql
CREATE INDEX idx_composition_feature_vector
ON composition_feature
USING ivfflat (feature_vector vector_cosine_ops)
WITH (lists = 100);
```
支持:
```text
Cosine Similarity
```
检索。
---
# 8 入库流程
## Step1
上传音频
---
## Step2
音频标准化
```text
22050Hz
Mono
```
---
## Step3
提取Chromagram
```text
12 × T
```
---
## Step4
重采样
```text
12 × 512
```
---
## Step5
展开
```text
6144维
```
---
## Step6
写入数据库
---
# 9 查询流程
## Step1
新歌曲进入系统
---
## Step2
重复执行:
```text
音频
Chromagram
12×512
6144维
```
---
## Step3
Cosine Similarity检索
返回:
```text
Top100
```
候选。
---
## Step4
重复判定
根据阈值判断:
```text
重复
疑似重复
非重复
```
---
# 10 相似度计算
采用:
Cosine Similarity
公式:
```text
sim(A,B)
=
(A·B)
/
(|A||B|)
```
结果:
```text
0~1
```
---
建议阈值(通过实验调整):
```text
>0.95
```
高度重复。
---
```text
0.85~0.95
```
疑似重复。
---
```text
<0.85
```
非重复。
最终通过实验调整。
---
# 11 V1验证目标
验证以下问题:
## 问题1
同一首歌不同版本:
```text
原唱
伴奏版
钢琴版
翻唱版
```
是否具有较高相似度。
---
## 问题2
不同歌曲之间:
```text
晴天
七里香
稻香
```
是否能够有效区分。
---
## 问题3
Chromagram Sequence是否具备作曲结构表达能力。
---
## 问题4
转调翻唱对相似度的影响程度,
是否需要引入 Key 归一化。
---
# 12 V2升级路线
### 片段 vs 全曲 / 片段 vs 片段
引入:
```text
帧级 CENS 特征 + FAISS 倒排索引
```
支持:
* 全曲 vs 片段去重
* 片段 vs 片段去重
### DTW 精排
保持:
```text
12 × 512
```
数据结构不变。
新增:
```text
DTW
```
作为精排算法。
流程:
```text
Cosine召回
Top100
DTW精排
Top10
```
无需修改数据库。
---
# 13 V3升级路线
引入:
```text
Faiss
```
或:
```text
Milvus
```
实现百万级曲库检索。
同时保留:
```text
DTW精排
```
机制。
---
# 14 最终方案总结
V1采用:
```text
音频
→ Chromagram
→ 时间归一化(12×512)
→ 6144维作曲指纹
→ pgvector
→ Cosine Similarity
→ 重复判定
```
方案特点:
* 仅处理完整版 vs 完整版
* 不依赖声纹模型
* 不依赖人声分离
* 不依赖和弦识别
* 保留完整时间结构
* 与录音去重模块职责完全分离
* 可直接升级至DTW方案
* 避免后续数据结构重构
* 工程实现复杂度低
* 适合作为曲去重系统V1版本
# 歌曲作曲结构去重系统 V2 优化方案
## 1 V1 评估基线
测试集:20 首歌 × 5 种变换 + 20 条负样本,共 120 条。
| 指标 | V1 结果 |
|---|---|
| Accuracy | 0.6917 |
| Precision | 1.0000 |
| Recall | 0.6300 |
| F1 | 0.7730 |
| 假阳性(FP) | 0 |
| 漏判(FN) | 37 |
按变换类型分解:
| 变换类型 | V1 Accuracy | 说明 |
|---|---|---|
| lowpass(低通滤波)| 1.00 | 音色变化,CENS 完全应对 |
| negative(不同曲)| 1.00 | 假阳性为零,区分度良好 |
| tempo_fast(加速 10%)| 0.80 | 时间压缩,部分吸收 |
| pitch_down(降半音)| 0.50 | 转调鲁棒性不足 |
| tempo_slow(减速 10%)| 0.45 | 时间拉伸,信息密度变化大 |
| pitch_up(升半音)| 0.40 | 转调鲁棒性最差 |
**核心结论:**
- 精确率满分,系统不会误判不同歌曲,可以放心部署
- 主要缺陷集中在转调(pitch)和减速(tempo_slow),合计贡献了 37 条漏判中的约 30 条
- V1 的主音对齐(`np.roll` 到能量最大音级)对连续半音偏移效果有限
---
## 2 问题根因分析
### 2.1 转调(pitch_up / pitch_down)失败的根本原因
ffmpeg `asetrate` 变调是连续音高偏移(非整数半音),而 CENS 的 12 个 bin 是离散的。`np.roll` 只能对齐整数个半音,偏移量不是整数半音时无法对齐,导致相似度显著下降(失败样本 top1 相似度集中在 0.78~0.90,未过阈值)。
### 2.2 减速(tempo_slow)失败的根本原因
时间拉伸后每帧重复内容增多,resample 到 128 帧时对应的音乐内容在时间轴上与原版错位。问题本质是**时间对齐失败**,而非特征表达力不足——多尺度拼接解决的是后者,对前者无效。唯一有效的解法是允许时间轴弹性匹配的 DTW。
---
## 3 V2 优化方案
### 3.1 转调鲁棒性:12 路循环对齐
**方案:** 查询时对特征矩阵穷举 12 种半音偏移(`np.roll(chroma, -k, axis=0)`,k=0..11),取与库中向量相似度最高的那一路作为最终相似度。
**实现位置:** `service.py``query()` 方法,特征提取本身不变。
**代价:** 每次查询变为 12 次向量检索,pgvector 单次检索延迟约 10ms 级别,12 路合计约 100ms,可接受。
**预期提升:** pitch_up / pitch_down accuracy 从 0.4/0.5 提升至接近 1.0。
```python
# 伪代码示意
best: dict[int, float] = {}
for shift in range(12):
shifted = np.roll(chroma, -shift, axis=0).flatten()
for song_id, sim in query_vector(shifted, top_k):
best[song_id] = max(best.get(song_id, -1.0), sim)
```
---
### 3.2 速度变化鲁棒性:DTW 精排
**方案:** Cosine 召回 Top-K 后,用 DTW 对原始 `12×128` 矩阵做精排,替换纯相似度阈值判断。
**实现:**
```python
from scipy.spatial.distance import cdist
def dtw_distance(chroma_a: np.ndarray, chroma_b: np.ndarray) -> float:
# chroma shape: (12, 128),按列(时间帧)计算帧间欧氏距离矩阵,再做 DTW
...
```
**适用场景:** 对 tempo 变化(±20% 以内)效果显著,对大幅转调效果有限(需配合 3.1)。
**部署策略:** 不修改数据库结构,仅在召回后增加一个 Python 计算步骤。
**预期提升:** tempo_slow accuracy 从 0.45 提升至 0.85 以上。
---
### 3.3 调性估计升级(可选)
**方案:** 用 Krumhansl-Schmuckler 调性轮廓替代"能量最大音级"作为主音估计,使 `np.roll` 对齐更精准。
**评估方式:** 先跑 3.1(穷举 12 路),若效果已满足需求则跳过本项。
---
## 4 优先级与实施顺序
| 优先级 | 方案 | 改动范围 | 预期收益 |
|---|---|---|---|
| P0 | 3.1 12 路循环对齐 | `service.py` query 逻辑 | pitch 问题基本解决 |
| P1 | 3.2 DTW 精排 | 新增精排函数,不改数据库 | tempo 问题显著改善 |
| P2 | 3.3 调性估计升级 | `extractor.py` | 按需实施 |
P0 和 P1 不需要重新入库,改动最小,建议优先实施并重新跑评估基线后再决定是否做 P2。
---
## 5 V2 目标指标
| 变换类型 | V1 | V2 目标 |
|---|---|---|
| lowpass | 1.00 | 1.00 |
| negative | 1.00 | 1.00 |
| tempo_fast | 0.80 | ≥ 0.95 |
| pitch_down | 0.50 | ≥ 0.95 |
| tempo_slow | 0.45 | ≥ 0.85 |
| pitch_up | 0.40 | ≥ 0.95 |
| **整体 Recall** | **0.63** | **≥ 0.92** |
| **整体 F1** | **0.773** | **≥ 0.958** |
Precision 维持 1.0(不引入假阳性)。