test-go / test (push) Successful in 1m0s
test-go / integration (push) Successful in 3m17s
release / Build signed APK (releases and dev) (push) Successful in 4m51s
release / Build + push container image (push) Successful in 14s
release / Verify release artifacts (tag releases only) (push) Skipped
Reads fingerprints, runs them through the matcher, and records proposals in duplicate_groups (migration 0059). Nothing is merged or deleted: a group is a proposal for the admin report (#3912). Streaming. The whole library's fingerprints are hundreds of megabytes, but tracks are only compared within 3s of each other in duration. So candidates stream in (duration_ms, id) order, keyset-paged on a new tracks(duration_ms, id) index. The grouper holds only the tracks within 3s of the oldest one not yet settled. A seed is settled once a track arrives beyond its window, which gives the same result as grouping the whole sorted list. groupDuplicates is rebuilt on the same streamGrouper, so there is one grouping rule and the #3909 tests still cover it. Each fingerprint's alignment index and variety check are computed once instead of for every pair. Exact duplicates are grouped library-wide in SQL. The first member the stream meets stands in for the whole group in the acoustic pass. An exact group caught in an oversize acoustic cluster is still proposed: the acoustic evidence is discarded, identical bytes are not. Re-sweeping: - a group is identified by its sorted member ids, so finding it again refreshes the row in place - a proposal whose members all sat in one dismissed group is not proposed again (a subset repeats the verdict; a superset is new evidence) - a pending proposal no sweep has found again is retired, but only after a complete sweep, and only if an earlier sweep last confirmed it, so two overlapping sweeps cannot delete each other's findings - dismissals are kept DuplicateSweepWorker checks hourly and sweeps only when a fingerprint was written after the last sweep started. TryStartDuplicateSweep guards against two sweeps at once and reaps one stuck in flight for 2h. The sweep row is closed on a detached context with a deadline, so a sweep cancelled at shutdown still records that it ended. The integration test pages one row at a time and checks: - an acoustic pair and an exact pair are found - a track with no fingerprint, a missing track and a near-duration unrelated song are left out - a dismissed group is suppressed while the pending one refreshes without duplicating - a proposal that stops holding is retired and the dismissal survives Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SQ31KQpYbStyK5y58UmPLH
431 lines
15 KiB
Go
431 lines
15 KiB
Go
package library
|
|
|
|
import (
|
|
"math"
|
|
"math/bits"
|
|
"sort"
|
|
)
|
|
|
|
// Duplicate matching (M400 #3909).
|
|
//
|
|
// Pure functions over fingerprints: no database, no files. This is the part that
|
|
// decides whether two tracks in the operator's library are proposed as one
|
|
// recording, so every rule in it has to be falsifiable in a unit test.
|
|
//
|
|
// Two tiers, answering different questions:
|
|
//
|
|
// exact equal audio_stream_sha256 — the same encoded audio bytes. No score,
|
|
// no threshold, no false positives (the #3885 pair).
|
|
// acoustic chromaprint fingerprints that agree closely once aligned — the
|
|
// same recording at another bitrate or in another codec.
|
|
//
|
|
// The acoustic comparison follows the approach of AcoustID's pg_acoustid
|
|
// (acoustid_compare.c): vote on the relative offset between two fingerprints
|
|
// using items that agree in their high bits, then measure disagreement at the
|
|
// winning offset. Reimplemented from that description; no code was copied.
|
|
// The alignment window and match-bit width below are taken from it.
|
|
|
|
// maxAlignOffsetItems bounds how far apart two fingerprints may be shifted and
|
|
// still be compared: ±120 items, about 15 seconds at chromaprint's ~8 items per
|
|
// second. Covers a leading silence trimmed differently or a short intro; the
|
|
// same bound pg_acoustid uses (ACOUSTID_MAX_ALIGN_OFFSET).
|
|
const maxAlignOffsetItems = 120
|
|
|
|
// alignMatchBits is how many high bits two items must share to vote for an
|
|
// offset. Matching whole 32-bit items would miss the same recording at another
|
|
// bitrate, whose low bits are noisier; 14 is pg_acoustid's MATCH_BITS.
|
|
const alignMatchBits = 14
|
|
|
|
// minOverlapItems is the least overlap worth a verdict: about 10 seconds. A few
|
|
// items agreeing perfectly is not evidence that two recordings are one.
|
|
const minOverlapItems = 80
|
|
|
|
// minDistinctFraction rejects low-information fingerprints before they can
|
|
// match. Near-silence, a sustained tone or a click track produces the same few
|
|
// items over and over, and two such tracks agree closely without being the
|
|
// same recording. Real music is overwhelmingly distinct item to item, so this
|
|
// floor only catches the pathological case. A judgment value, not a measured
|
|
// one — revisit if the sweep reports real tracks refused for it.
|
|
const minDistinctFraction = 0.3
|
|
|
|
// defaultAcousticMaxBitErrorRate is the most disagreement two aligned
|
|
// fingerprints may show and still be proposed as one recording. Unrelated audio
|
|
// sits near 0.5; the same recording re-encoded lands well under 0.1.
|
|
//
|
|
// Deliberately conservative. The operator's stated worry is the opposite of a
|
|
// missed duplicate: "the same song can appear in different albums, usually it's
|
|
// a different recording", and an instrumental shares its vocal version's
|
|
// harmony, which chroma features capture. A false merge is the failure that
|
|
// matters, and the report is reviewed anyway. This is an unmeasured default:
|
|
// calibrate it against real pairs once the backfill (#3908) has populated the
|
|
// library, then expose it in Settings (#3913).
|
|
const defaultAcousticMaxBitErrorRate = 0.15
|
|
|
|
// durationToleranceMs is how far apart two tracks' durations may be and still be
|
|
// compared. Encoders pad and trim a little; different edits differ by more.
|
|
const durationToleranceMs = 3000
|
|
|
|
// maxAcousticGroupSize caps an acoustic group. A cluster bigger than this is far
|
|
// more likely a shared jingle, a skit or a low-information pattern than eight
|
|
// copies of one recording, and proposing it would bury the real duplicates.
|
|
// Exact-tier groups are not capped: identical bytes are identical however many.
|
|
const maxAcousticGroupSize = 8
|
|
|
|
// acousticScore is the result of comparing two fingerprints.
|
|
type acousticScore struct {
|
|
// Offset is how many items b is shifted against a: b[i+Offset] aligns with
|
|
// a[i].
|
|
Offset int
|
|
// Overlap is how many aligned items were compared.
|
|
Overlap int
|
|
// BitErrorRate is the fraction of differing bits over the overlap, 0..1.
|
|
BitErrorRate float64
|
|
}
|
|
|
|
// preparedPrint is a fingerprint with the parts every comparison needs worked
|
|
// out once. The sweep compares each track with every other track within a few
|
|
// seconds of its duration, so rebuilding the alignment index for each pair would
|
|
// dominate its cost.
|
|
type preparedPrint struct {
|
|
items []int32
|
|
index map[uint32][]int
|
|
informative bool
|
|
}
|
|
|
|
// preparePrint indexes a fingerprint's items by their high bits and records
|
|
// whether it varies enough to be compared at all.
|
|
func preparePrint(fp []int32) *preparedPrint {
|
|
// Each bucket keeps only a few positions: a value repeating many times is
|
|
// uninformative, and letting it vote once per repeat would make every
|
|
// pairing O(n²).
|
|
const keepPerBucket = 4
|
|
p := &preparedPrint{items: fp, index: make(map[uint32][]int, len(fp))}
|
|
seen := make(map[int32]struct{}, len(fp))
|
|
for i, v := range fp {
|
|
seen[v] = struct{}{}
|
|
key := alignKey(v)
|
|
if pos := p.index[key]; len(pos) < keepPerBucket {
|
|
p.index[key] = append(pos, i)
|
|
}
|
|
}
|
|
p.informative = len(fp) > 0 && float64(len(seen)) >= minDistinctFraction*float64(len(fp))
|
|
return p
|
|
}
|
|
|
|
func alignKey(v int32) uint32 { return uint32(v) >> (32 - alignMatchBits) }
|
|
|
|
// compareChromaprint aligns two raw fingerprints and measures how much they
|
|
// disagree. ok is false when no verdict is possible: no offset gathered any
|
|
// votes, the overlap at the best offset is too short, or either side carries
|
|
// too little information to mean anything.
|
|
func compareChromaprint(a, b []int32) (acousticScore, bool) {
|
|
return comparePrepared(preparePrint(a), preparePrint(b))
|
|
}
|
|
|
|
// comparePrepared is compareChromaprint over fingerprints already prepared.
|
|
func comparePrepared(a, b *preparedPrint) (acousticScore, bool) {
|
|
if len(a.items) < minOverlapItems || len(b.items) < minOverlapItems {
|
|
return acousticScore{}, false
|
|
}
|
|
if !a.informative || !b.informative {
|
|
return acousticScore{}, false
|
|
}
|
|
|
|
offset, ok := bestOffset(a, b)
|
|
if !ok {
|
|
return acousticScore{}, false
|
|
}
|
|
|
|
// a[i] aligns with b[i+offset]; walk the indices valid on both sides.
|
|
start := max(0, -offset)
|
|
end := min(len(a.items), len(b.items)-offset)
|
|
overlap := end - start
|
|
if overlap < minOverlapItems {
|
|
return acousticScore{}, false
|
|
}
|
|
errBits := 0
|
|
for i := start; i < end; i++ {
|
|
errBits += bits.OnesCount32(uint32(a.items[i]) ^ uint32(b.items[i+offset]))
|
|
}
|
|
return acousticScore{
|
|
Offset: offset,
|
|
Overlap: overlap,
|
|
BitErrorRate: float64(errBits) / float64(32*overlap),
|
|
}, true
|
|
}
|
|
|
|
// bestOffset returns the relative shift most items agree on.
|
|
func bestOffset(a, b *preparedPrint) (int, bool) {
|
|
votes := make([]int, 2*maxAlignOffsetItems+1)
|
|
for j, v := range b.items {
|
|
for _, i := range a.index[alignKey(v)] {
|
|
off := j - i
|
|
if off >= -maxAlignOffsetItems && off <= maxAlignOffsetItems {
|
|
votes[off+maxAlignOffsetItems]++
|
|
}
|
|
}
|
|
}
|
|
|
|
best, bestVotes := 0, 0
|
|
for k, n := range votes {
|
|
// Strictly greater keeps the smallest shift on a tie, which is the more
|
|
// likely truth and keeps the result deterministic.
|
|
if n > bestVotes || (n == bestVotes && n > 0 && abs(k-maxAlignOffsetItems) < abs(best)) {
|
|
best, bestVotes = k-maxAlignOffsetItems, n
|
|
}
|
|
}
|
|
return best, bestVotes > 0
|
|
}
|
|
|
|
// fingerprintCandidate is one track as the grouping sees it.
|
|
type fingerprintCandidate struct {
|
|
ID string
|
|
DurationMs int32
|
|
StreamSHA256 []byte
|
|
Chromaprint []int32
|
|
}
|
|
|
|
// duplicateTier names what a group's evidence is.
|
|
type duplicateTier string
|
|
|
|
const (
|
|
tierExact duplicateTier = "exact"
|
|
tierAcoustic duplicateTier = "acoustic"
|
|
)
|
|
|
|
// duplicateGroup is a set of tracks proposed as one recording. Members are
|
|
// sorted by ID.
|
|
type duplicateGroup struct {
|
|
Tier duplicateTier
|
|
Members []string
|
|
// WorstBitErrorRate is the largest disagreement between any two members of
|
|
// an acoustic group — the weakest evidence the group rests on. Zero for
|
|
// exact groups.
|
|
WorstBitErrorRate float64
|
|
}
|
|
|
|
// groupingResult is what one grouping pass found.
|
|
type groupingResult struct {
|
|
Groups []duplicateGroup
|
|
// OversizeClusters counts acoustic clusters discarded for exceeding
|
|
// maxAcousticGroupSize. Reported rather than silent: a sudden rise means the
|
|
// cap or the information floor needs attention.
|
|
OversizeClusters int
|
|
}
|
|
|
|
// groupUnit is one thing the acoustic pass compares: a single track, or an exact
|
|
// group standing in for all its byte-identical copies.
|
|
type groupUnit struct {
|
|
ids []string // every member, sorted
|
|
durationMs int32
|
|
sortKey string // the representative's id: ties on duration break on it
|
|
print *preparedPrint
|
|
exact bool // more than one member with identical audio
|
|
assigned bool
|
|
}
|
|
|
|
// streamGrouper is the acoustic pass over units arriving in (durationMs, sortKey)
|
|
// order. It holds only the units within durationToleranceMs of the oldest one
|
|
// not yet settled, so memory is bounded by the densest few seconds of the
|
|
// library rather than by its size — the whole library's fingerprints would be
|
|
// hundreds of megabytes.
|
|
//
|
|
// A seed can be settled as soon as a unit arrives beyond its window: everything
|
|
// it could group with has already arrived, and no later seed can reach back to
|
|
// it because seeds only look forward. That is what makes the streamed result
|
|
// identical to running the same pass over the whole sorted list.
|
|
//
|
|
// Grouping is COMPLETE-LINKAGE: a unit joins a group only if it matches every
|
|
// unit already in it, within the duration tolerance and the bit-error limit.
|
|
// Single-linkage would let a chain of near-misses — A close to B, B close to C —
|
|
// drag A and C, which are not close, into one proposed merge. Complete linkage
|
|
// also means any member can be chosen as the survivor (#3911).
|
|
//
|
|
// When an acoustic group absorbs an exact group, the result is tier acoustic: a
|
|
// group is only as certain as its weakest link.
|
|
type streamGrouper struct {
|
|
maxBitErrorRate float64
|
|
window []*groupUnit
|
|
res groupingResult
|
|
}
|
|
|
|
func newStreamGrouper(maxBitErrorRate float64) *streamGrouper {
|
|
return &streamGrouper{maxBitErrorRate: maxBitErrorRate}
|
|
}
|
|
|
|
// push adds the next unit. Units must arrive in non-decreasing
|
|
// (durationMs, sortKey) order.
|
|
func (g *streamGrouper) push(u *groupUnit) {
|
|
g.window = append(g.window, u)
|
|
for len(g.window) > 1 && u.durationMs-g.window[0].durationMs > durationToleranceMs {
|
|
g.settleOldest()
|
|
}
|
|
}
|
|
|
|
// finish settles every unit still waiting and returns what was found. Groups
|
|
// are in no particular order; callers sort with sortGroups.
|
|
func (g *streamGrouper) finish() groupingResult {
|
|
for len(g.window) > 0 {
|
|
g.settleOldest()
|
|
}
|
|
return g.res
|
|
}
|
|
|
|
func (g *streamGrouper) settleOldest() {
|
|
seed := g.window[0]
|
|
g.window[0] = nil // release it: the window's backing array outlives the slide
|
|
g.window = g.window[1:]
|
|
if seed.assigned {
|
|
return
|
|
}
|
|
|
|
group := []*groupUnit{seed}
|
|
worst := 0.0
|
|
for _, cand := range g.window {
|
|
if cand.durationMs-seed.durationMs > durationToleranceMs {
|
|
break
|
|
}
|
|
if cand.assigned {
|
|
continue
|
|
}
|
|
joined, worstWithCand := true, worst
|
|
for _, member := range group {
|
|
if abs32(cand.durationMs-member.durationMs) > durationToleranceMs {
|
|
joined = false
|
|
break
|
|
}
|
|
score, ok := comparePrepared(member.print, cand.print)
|
|
if !ok || score.BitErrorRate > g.maxBitErrorRate {
|
|
joined = false
|
|
break
|
|
}
|
|
worstWithCand = math.Max(worstWithCand, score.BitErrorRate)
|
|
}
|
|
if joined {
|
|
group = append(group, cand)
|
|
worst = worstWithCand
|
|
}
|
|
}
|
|
|
|
if len(group) == 1 {
|
|
if seed.exact {
|
|
g.res.Groups = append(g.res.Groups, duplicateGroup{Tier: tierExact, Members: seed.ids})
|
|
}
|
|
return
|
|
}
|
|
// Count units, not tracks: an absorbed exact group is one piece of acoustic
|
|
// evidence however many identical files it holds.
|
|
if len(group) > maxAcousticGroupSize {
|
|
g.res.OversizeClusters++
|
|
for _, member := range group {
|
|
member.assigned = true
|
|
// The acoustic evidence is untrustworthy; identical bytes are not.
|
|
// An exact group caught inside an oversize cluster is still proposed.
|
|
if member.exact {
|
|
g.res.Groups = append(g.res.Groups, duplicateGroup{Tier: tierExact, Members: member.ids})
|
|
}
|
|
}
|
|
return
|
|
}
|
|
var members []string
|
|
for _, member := range group {
|
|
member.assigned = true
|
|
members = append(members, member.ids...)
|
|
}
|
|
sort.Strings(members)
|
|
g.res.Groups = append(g.res.Groups, duplicateGroup{
|
|
Tier: tierAcoustic, Members: members, WorstBitErrorRate: worst,
|
|
})
|
|
}
|
|
|
|
// groupDuplicates proposes duplicate groups among candidates held in memory. It
|
|
// runs the same streamGrouper the sweep uses, so there is one grouping rule.
|
|
//
|
|
// Exact groups come first: tracks sharing an audio stream hash. Each becomes a
|
|
// single unit for the acoustic pass, represented by its member with the lowest
|
|
// (duration, id) that has a chromaprint. That is the member the sweep's
|
|
// duration-ordered stream meets first, which keeps the two identical. An exact
|
|
// group with no chromaprint at all cannot be compared acoustically and stands
|
|
// on its own.
|
|
//
|
|
// The output does not depend on input order.
|
|
func groupDuplicates(cands []fingerprintCandidate, maxBitErrorRate float64) groupingResult {
|
|
byHash := map[string][]fingerprintCandidate{}
|
|
var units []*groupUnit
|
|
var printless []duplicateGroup
|
|
for _, c := range cands {
|
|
if len(c.StreamSHA256) > 0 {
|
|
byHash[string(c.StreamSHA256)] = append(byHash[string(c.StreamSHA256)], c)
|
|
continue
|
|
}
|
|
if len(c.Chromaprint) > 0 {
|
|
units = append(units, &groupUnit{
|
|
ids: []string{c.ID}, durationMs: c.DurationMs, sortKey: c.ID, print: preparePrint(c.Chromaprint),
|
|
})
|
|
}
|
|
}
|
|
for _, group := range byHash {
|
|
ids := make([]string, len(group))
|
|
for i, m := range group {
|
|
ids[i] = m.ID
|
|
}
|
|
sort.Strings(ids)
|
|
|
|
var rep *fingerprintCandidate
|
|
for i := range group {
|
|
m := &group[i]
|
|
if len(m.Chromaprint) == 0 {
|
|
continue
|
|
}
|
|
if rep == nil || m.DurationMs < rep.DurationMs || (m.DurationMs == rep.DurationMs && m.ID < rep.ID) {
|
|
rep = m
|
|
}
|
|
}
|
|
if rep == nil {
|
|
if len(group) > 1 {
|
|
printless = append(printless, duplicateGroup{Tier: tierExact, Members: ids})
|
|
}
|
|
continue
|
|
}
|
|
units = append(units, &groupUnit{
|
|
ids: ids, durationMs: rep.DurationMs, sortKey: rep.ID,
|
|
print: preparePrint(rep.Chromaprint), exact: len(group) > 1,
|
|
})
|
|
}
|
|
|
|
sort.Slice(units, func(i, j int) bool {
|
|
if units[i].durationMs != units[j].durationMs {
|
|
return units[i].durationMs < units[j].durationMs
|
|
}
|
|
return units[i].sortKey < units[j].sortKey
|
|
})
|
|
g := newStreamGrouper(maxBitErrorRate)
|
|
for _, u := range units {
|
|
g.push(u)
|
|
}
|
|
res := g.finish()
|
|
res.Groups = append(res.Groups, printless...)
|
|
sortGroups(res.Groups)
|
|
return res
|
|
}
|
|
|
|
// sortGroups orders groups by their first member. Groups are disjoint, so that
|
|
// is a total order.
|
|
func sortGroups(groups []duplicateGroup) {
|
|
sort.Slice(groups, func(i, j int) bool { return groups[i].Members[0] < groups[j].Members[0] })
|
|
}
|
|
|
|
func abs(n int) int {
|
|
if n < 0 {
|
|
return -n
|
|
}
|
|
return n
|
|
}
|
|
|
|
func abs32(n int32) int32 {
|
|
if n < 0 {
|
|
return -n
|
|
}
|
|
return n
|
|
}
|