release / govulncheck (push) Successful in 20s
release / web (push) Successful in 1m20s
release / go (push) Successful in 1m37s
release / integration (push) Successful in 5m15s
release / android (push) Successful in 5m26s
release / Build signed APK (releases and dev) (push) Successful in 5m45s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 16s
release / Verify release artifacts (tag releases only) (push) Skipped
Rewrites the internal/library comment blocks to rule 32. Task, milestone, decision and rule ids are gone; the incidents behind them are restated as the constraint they left (why a file is deleted before its row, why the missing mark is a mark, why duplicates block on duration). Counts and album names from one library are dropped from production comments; test comments keep the names their fixtures use. The tagReadVersion log keeps each version's before and after, without the measurements. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
431 lines
14 KiB
Go
431 lines
14 KiB
Go
package library
|
|
|
|
import (
|
|
"math"
|
|
"math/bits"
|
|
"sort"
|
|
)
|
|
|
|
// Duplicate matching.
|
|
//
|
|
// Pure functions over fingerprints: no database, no files. This is the part
|
|
// that decides whether two tracks in the 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.
|
|
// 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. A false merge is the failure that matters, worse
|
|
// than a missed duplicate: the same song on different albums is usually a
|
|
// different recording, and an instrumental shares its vocal version's harmony,
|
|
// which chroma features capture. An admin can change it in Settings
|
|
// (FingerprintSettings).
|
|
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.
|
|
//
|
|
// 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
|
|
}
|