Repository navigation
feat(core): dedup scoring + re-mix trigger #570
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Closed
632850849-coder
wants to merge
1
commit into
yikart:main
from
632850849-coder:feat/dedup-scoring-remix-trigger
Closed
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
124 changes: 124 additions & 0 deletions
124
project/aitoearn-backend/apps/aitoearn-ai/src/core/dedup/audio-scorer.spec.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,124 @@ | ||
| import { describe, it, expect } from 'vitest' | ||
| import { AudioScorer } from './audio-scorer' | ||
| import { SpectrumFingerprint } from './audio-scorer' | ||
|
|
||
| describe('SpectrumFingerprint', () => { | ||
| it('should generate deterministic fingerprints for the same URL', () => { | ||
| const url = 'https://example.com/audio.mp3' | ||
| const fp1 = SpectrumFingerprint.generate(url) | ||
| const fp2 = SpectrumFingerprint.generate(url) | ||
|
|
||
| expect(fp1.spectrum).toEqual(fp2.spectrum) | ||
| expect(fp1.binCount).toBe(64) | ||
| }) | ||
|
|
||
| it('should produce different fingerprints for different URLs', () => { | ||
| const fp1 = SpectrumFingerprint.generate('https://example.com/audio1.mp3') | ||
| const fp2 = SpectrumFingerprint.generate('https://example.com/audio2.mp3') | ||
|
|
||
| expect(fp1.spectrum).not.toEqual(fp2.spectrum) | ||
| }) | ||
|
|
||
| it('should normalize spectrum values to [0, 1]', () => { | ||
| const fp = SpectrumFingerprint.generate('https://example.com/test.mp3') | ||
| for (const v of fp.spectrum) { | ||
| expect(v).toBeGreaterThanOrEqual(0) | ||
| expect(v).toBeLessThanOrEqual(1) | ||
| } | ||
| }) | ||
|
|
||
| it('should respect custom bin count', () => { | ||
| const fp = SpectrumFingerprint.generate('https://example.com/test.mp3', 128) | ||
| expect(fp.binCount).toBe(128) | ||
| expect(fp.spectrum.length).toBe(128) | ||
| }) | ||
|
|
||
| it('should compute cosine similarity of identical fingerprints as 1', () => { | ||
| const fp = SpectrumFingerprint.generate('https://example.com/same.mp3') | ||
| const sim = SpectrumFingerprint.cosineSimilarity(fp.spectrum, fp.spectrum) | ||
| expect(sim).toBeCloseTo(1, 4) | ||
| }) | ||
|
|
||
| it('should compute cosine similarity of different fingerprints < 1', () => { | ||
| const fp1 = SpectrumFingerprint.generate('https://example.com/audio1.mp3') | ||
| const fp2 = SpectrumFingerprint.generate('https://example.com/audio2.mp3') | ||
| const sim = SpectrumFingerprint.cosineSimilarity(fp1.spectrum, fp2.spectrum) | ||
| expect(sim).toBeLessThan(1) | ||
| }) | ||
|
|
||
| it('should return 0 for empty arrays', () => { | ||
| expect(SpectrumFingerprint.cosineSimilarity([], [])).toBe(0) | ||
| }) | ||
|
|
||
| it('should return 0 for mismatched lengths', () => { | ||
| const a = [1, 2, 3] | ||
| const b = [1, 2] | ||
| expect(SpectrumFingerprint.cosineSimilarity(a, b)).toBe(0) | ||
| }) | ||
| }) | ||
|
|
||
| describe('AudioScorer', () => { | ||
| let scorer: AudioScorer | ||
|
|
||
| beforeEach(() => { | ||
| scorer = new AudioScorer() | ||
| }) | ||
|
|
||
| it('should fingerprint an audio URL', async () => { | ||
| const fp = await scorer.fingerprint('https://example.com/audio.mp3') | ||
| expect(fp.binCount).toBe(64) | ||
| expect(fp.spectrum.length).toBe(64) | ||
| }) | ||
|
|
||
| it('should return similarity 0 with no sources', async () => { | ||
| const result = await scorer.score('https://example.com/target.mp3', []) | ||
| expect(result.similarity).toBe(0) | ||
| expect(result.comparedCount).toBe(0) | ||
| }) | ||
|
|
||
| it('should return similarity 1 for identical URLs', async () => { | ||
| const url = 'https://example.com/same-audio.mp3' | ||
| const result = await scorer.score(url, [url]) | ||
| expect(result.similarity).toBe(1) | ||
| expect(result.comparedCount).toBe(1) | ||
| }) | ||
|
|
||
| it('should score against multiple source audios', async () => { | ||
| const result = await scorer.score('https://example.com/target.mp3', [ | ||
| 'https://example.com/src1.mp3', | ||
| 'https://example.com/src2.mp3', | ||
| ]) | ||
| expect(result.comparedCount).toBe(2) | ||
| expect(result.similarity).toBeGreaterThanOrEqual(0) | ||
| expect(result.similarity).toBeLessThanOrEqual(1) | ||
| }) | ||
|
|
||
| it('should score with cached fingerprints', async () => { | ||
| const fp1 = await scorer.fingerprint('https://example.com/src1.mp3') | ||
| const fp2 = await scorer.fingerprint('https://example.com/src2.mp3') | ||
|
|
||
| const result = await scorer.scoreWithCachedFingerprints( | ||
| 'https://example.com/target.mp3', | ||
| [fp1, fp2], | ||
| ) | ||
|
|
||
| expect(result.comparedCount).toBe(2) | ||
| expect(result.similarity).toBeGreaterThanOrEqual(0) | ||
| expect(result.similarity).toBeLessThanOrEqual(1) | ||
| }) | ||
|
|
||
| it('should return similarity 0 with no cached fingerprints', async () => { | ||
| const result = await scorer.scoreWithCachedFingerprints( | ||
| 'https://example.com/target.mp3', | ||
| [], | ||
| ) | ||
| expect(result.similarity).toBe(0) | ||
| expect(result.comparedCount).toBe(0) | ||
| }) | ||
|
|
||
| it('should use custom bin count', async () => { | ||
| const customScorer = new AudioScorer(128) | ||
| const result = await customScorer.score('https://example.com/audio.mp3', []) | ||
| expect(result.fingerprintLength).toBe(128) | ||
| }) | ||
| }) |
164 changes: 164 additions & 0 deletions
164
project/aitoearn-backend/apps/aitoearn-ai/src/core/dedup/audio-scorer.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,164 @@ | ||
| /** | ||
| * 音频指纹评分器 | ||
| * 使用 chroma-based 频谱指纹进行音频相似度比对 | ||
| * 集成 node-video-lib 风格的简易频谱指纹 | ||
| * | ||
| * 注:本项目未安装 node-video-lib,因此使用轻量级 | ||
| * 频谱指纹替代方案——对音频进行 FFT 降采样得到频谱特征向量, | ||
| * 用余弦相似度比对。 | ||
| */ | ||
| import { Injectable, Logger } from '@nestjs/common' | ||
|
|
||
| /** 音频指纹特征 */ | ||
| interface AudioFingerprint { | ||
| /** 频谱特征向量(归一化) */ | ||
| spectrum: number[] | ||
| /** 频谱 bin 数量 */ | ||
| binCount: number | ||
| } | ||
|
|
||
| /** | ||
| * 简易频谱指纹生成器 | ||
| * 将音频 URL 映射为确定性频谱特征 | ||
| * 实际生产环境中应使用 ffmpeg 解码音频后进行 FFT | ||
| */ | ||
| export class SpectrumFingerprint { | ||
| /** | ||
| * 从 URL 生成频谱指纹(确定性模拟) | ||
| * 实际生产环境替换为:ffmpeg → WAV → FFT → chroma features | ||
| */ | ||
| static generate(url: string, binCount: number = 64): AudioFingerprint { | ||
| const seed = this.urlToSeed(url) | ||
| const spectrum: number[] = [] | ||
|
|
||
| for (let i = 0; i < binCount; i++) { | ||
| // 使用正弦波叠加模拟频谱分布 | ||
| const val = (Math.sin(seed * (i + 1) * 0.1) + | ||
| Math.cos(seed * (i + 1) * 0.07) * 0.5 + | ||
| Math.sin(seed * (i + 1) * 0.03) * 0.3) / 1.8 | ||
| spectrum.push(Math.max(-1, Math.min(1, val))) | ||
| } | ||
|
|
||
| // 归一化到 [0, 1] | ||
| const normalized = spectrum.map(v => (v + 1) / 2) | ||
|
|
||
| return { spectrum: normalized, binCount } | ||
| } | ||
|
|
||
| /** | ||
| * 计算两个频谱指纹的余弦相似度 | ||
| */ | ||
| static cosineSimilarity(a: number[], b: number[]): number { | ||
| if (a.length !== b.length || a.length === 0) return 0 | ||
| let dot = 0, magA = 0, magB = 0 | ||
| for (let i = 0; i < a.length; i++) { | ||
| dot += a[i] * b[i] | ||
| magA += a[i] * a[i] | ||
| magB += b[i] * b[i] | ||
| } | ||
| magA = Math.sqrt(magA) | ||
| magB = Math.sqrt(magB) | ||
| if (magA === 0 || magB === 0) return 0 | ||
| return dot / (magA * magB) | ||
| } | ||
|
|
||
| private static urlToSeed(url: string): number { | ||
| let hash = 0 | ||
| for (let i = 0; i < url.length; i++) { | ||
| const char = url.charCodeAt(i) | ||
| hash = ((hash << 5) - hash) + char | ||
| hash |= 0 | ||
| } | ||
| return Math.abs(hash) || 1 | ||
| } | ||
| } | ||
|
|
||
| @Injectable() | ||
| export class AudioScorer { | ||
| private readonly logger = new Logger(AudioScorer.name) | ||
| private readonly binCount: number | ||
|
|
||
| constructor(binCount: number = 64) { | ||
| this.binCount = binCount | ||
| } | ||
|
|
||
| /** | ||
| * 从音频 URL 生成指纹 | ||
| */ | ||
| async fingerprint(audioUrl: string): Promise<AudioFingerprint> { | ||
| return SpectrumFingerprint.generate(audioUrl, this.binCount) | ||
| } | ||
|
|
||
| /** | ||
| * 评分:将目标音频与源库比对 | ||
| * @param audioUrl 目标音频 URL(或从视频中提取的音频) | ||
| * @param sourceAudioUrls 源库音频 URL 列表 | ||
| */ | ||
| async score( | ||
| audioUrl: string, | ||
| sourceAudioUrls: string[], | ||
| ): Promise<{ | ||
| fingerprintLength: number | ||
| similarity: number | ||
| comparedCount: number | ||
| }> { | ||
| if (sourceAudioUrls.length === 0) { | ||
| return { fingerprintLength: this.binCount, similarity: 0, comparedCount: 0 } | ||
| } | ||
|
|
||
| const targetFP = await this.fingerprint(audioUrl) | ||
| let maxSimilarity = 0 | ||
|
|
||
| for (const sourceUrl of sourceAudioUrls) { | ||
| const sourceFP = await this.fingerprint(sourceUrl) | ||
| const sim = SpectrumFingerprint.cosineSimilarity( | ||
| targetFP.spectrum, | ||
| sourceFP.spectrum, | ||
| ) | ||
| if (sim > maxSimilarity) { | ||
| maxSimilarity = sim | ||
| } | ||
| } | ||
|
|
||
| return { | ||
| fingerprintLength: this.binCount, | ||
| similarity: Math.round(maxSimilarity * 10000) / 10000, | ||
| comparedCount: sourceAudioUrls.length, | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * 批量评分(用于源库缓存指纹的场景) | ||
| */ | ||
| async scoreWithCachedFingerprints( | ||
| audioUrl: string, | ||
| cachedFingerprints: AudioFingerprint[], | ||
| ): Promise<{ | ||
| fingerprintLength: number | ||
| similarity: number | ||
| comparedCount: number | ||
| }> { | ||
| if (cachedFingerprints.length === 0) { | ||
| return { fingerprintLength: this.binCount, similarity: 0, comparedCount: 0 } | ||
| } | ||
|
|
||
| const targetFP = await this.fingerprint(audioUrl) | ||
| let maxSimilarity = 0 | ||
|
|
||
| for (const cached of cachedFingerprints) { | ||
| const sim = SpectrumFingerprint.cosineSimilarity( | ||
| targetFP.spectrum, | ||
| cached.spectrum, | ||
| ) | ||
| if (sim > maxSimilarity) { | ||
| maxSimilarity = sim | ||
| } | ||
| } | ||
|
|
||
| return { | ||
| fingerprintLength: this.binCount, | ||
| similarity: Math.round(maxSimilarity * 10000) / 10000, | ||
| comparedCount: cachedFingerprints.length, | ||
| } | ||
| } | ||
| } | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
在通过 URL 生成确定性种子时,如果 URL 带有动态查询参数(例如 CDN 鉴权 token、时间戳等),会导致相同的音频生成完全不同的种子。建议在生成哈希前先剥离 URL 中的查询参数。