diff --git a/README.md b/README.md index 939a775..89f83c6 100644 --- a/README.md +++ b/README.md @@ -56,13 +56,13 @@ let duration = testFingerprint.duration // 46.0 Get the fingerprint as base64 representation ``` swift -let base64FingerprintString = testFingerprint.fingerprint! // AQABYJGikFSmJBCPijt6Hq..." +let base64FingerprintString = testFingerprint.fingerprint // "AQABYJGikFSmJBCPijt6Hq..." ``` Get the fingerprints hash as binary string ``` swift -let binaryHashString = testFingerprint.hash! // "01110100010011101010100110100100" +let binaryHashString = testFingerprint.hash // "01110100010011101010100110100100" ``` Instantiate a fingerprint object from its base64 representation and entire file duration @@ -74,13 +74,23 @@ let newFingerprint = try AudioFingerprint(from: base64FingerprintString, duratio Get similarity to other fingerprint object (`0.0` to `1.0`) ``` swift -newFingerprint.similarity(to: testFingerprint) // 1.0 +let similarity = try newFingerprint.similarity(to: testFingerprint) // 1.0 ``` -Or a hash as binary string +Optionally, ignore length differences greater than 50% between fingerprints (Default: `false`) + +*Note: This can lead to wrong results when comparing e.g. a Fingerprint sampled for 10 seconds to a Fingerprint sampled for 120 seconds* + +``` swift +let similarity = try newFingerprint.similarity(to: testFingerprint, ignoreLength: true) // 1.0 +``` + +You can also get the similarity to a fingerprint hash + +*Note: This is less accurate than comparing fingerprint objects, especially if the algorithms don't match* ``` swift -newFingerprint.similarity(to: binaryHashString) // 1.0 +let hashSimilarity = try newFingerprint.similarity(to: binaryHashString) // 1.0 ``` ### Looking up fingerprints @@ -142,7 +152,7 @@ Or an `AudioFingerprint.Error` ``` swift do { - try AudioFingerprint(from: "Invalid", duration: 10.0) + try AudioFingerprint(from: "Invalid", duration: 46.0) } catch { // AudioFingerprint.Error.invalidFingerprint } diff --git a/Sources/ChromaSwift/AcoustID.swift b/Sources/ChromaSwift/AcoustID.swift index 76b9dc1..eed04e2 100644 --- a/Sources/ChromaSwift/AcoustID.swift +++ b/Sources/ChromaSwift/AcoustID.swift @@ -68,16 +68,11 @@ public class AcoustID { } public func lookup(_ fingerprint: AudioFingerprint, completion: @escaping (Result<[APIResult], Error>) -> Void) { - guard let base64Fingerprint = fingerprint.fingerprint else { - completion(.failure(Error.invalidFingerprint)) - return - } - let query = [ URLQueryItem(name: "client", value: apiKey), URLQueryItem(name: "meta", value: "recordings+releasegroups+compress"), URLQueryItem(name: "duration", value: String(UInt(fingerprint.duration))), - URLQueryItem(name: "fingerprint", value: base64Fingerprint) + URLQueryItem(name: "fingerprint", value: fingerprint.fingerprint) ] var lookupURLComponents = URLComponents(string: lookupEndpoint)! lookupURLComponents.queryItems = query diff --git a/Sources/ChromaSwift/AudioFingerprint.swift b/Sources/ChromaSwift/AudioFingerprint.swift index b284808..d56111f 100644 --- a/Sources/ChromaSwift/AudioFingerprint.swift +++ b/Sources/ChromaSwift/AudioFingerprint.swift @@ -14,6 +14,9 @@ public class AudioFingerprint { case fingerprintingFailed case invalidDuration case invalidFingerprint + case invalidHash + case differentAlgorithm + case lenghtDifference } public enum Algorithm: Int32 { @@ -78,33 +81,27 @@ public class AudioFingerprint { self.init(from: [UInt32](UnsafeBufferPointer(start: rawFingerprintPointer, count: Int(rawFingerprintSize))), algorithm: Algorithm(rawValue: algorithm)!, duration: duration) } - public var fingerprint: String? { + public var fingerprint: String { var fingerprint: UnsafeMutablePointer? = UnsafeMutablePointer.allocate(capacity: 1) defer { fingerprint?.deallocate() } var fingerprintSize = Int32(0) - if chromaprint_encode_fingerprint(&rawFingerprint, Int32(rawFingerprint.count), algorithm.rawValue, &fingerprint, &fingerprintSize, 1) != 1 { - return nil - } + chromaprint_encode_fingerprint(&rawFingerprint, Int32(rawFingerprint.count), algorithm.rawValue, &fingerprint, &fingerprintSize, 1) return String(cString: fingerprint!) } - var rawHash: UInt32? { + var rawHash: UInt32 { var hash = UInt32(0) - if chromaprint_hash_fingerprint(&rawFingerprint, Int32(rawFingerprint.count), &hash) != 1 { - return nil - } + chromaprint_hash_fingerprint(&rawFingerprint, Int32(rawFingerprint.count), &hash) return hash } - public var hash: String? { - guard let rawHash = rawHash else { return nil } - + public var hash: String { let binaryString = String(rawHash, radix: 2) var paddedBinaryString = binaryString @@ -115,19 +112,39 @@ public class AudioFingerprint { return paddedBinaryString } - func similarity(to otherRawHash: UInt32) -> Double? { - guard let selfRawHash = rawHash else { return nil } - let diff = selfRawHash ^ otherRawHash - return Double(32 - String(diff, radix: 2).filter({ $0 == "1" }).count) / Double(32) + func similarity(to rawHash: UInt32) -> Double { + return Double(32 - (self.rawHash ^ rawHash).nonzeroBitCount) / Double(32) } - public func similarity(to otherHash: String) -> Double? { - guard let otherRawHash = UInt32(otherHash, radix: 2) else { return nil } - return similarity(to: otherRawHash) + public func similarity(to hash: String) throws -> Double { + guard let rawHash = UInt32(hash, radix: 2) else { + throw Error.invalidHash + } + return similarity(to: rawHash) } - public func similarity(to fingerprint: AudioFingerprint) -> Double? { - guard let otherRawHash = fingerprint.rawHash else { return nil } - return similarity(to: otherRawHash) + public func similarity(to fingerprint: AudioFingerprint, ignoreLength: Bool = false) throws -> Double { + if fingerprint.algorithm != algorithm { + throw Error.differentAlgorithm + } + + let sampleDifference = fingerprint.rawFingerprint.count - rawFingerprint.count + let biggerFingerprint = sampleDifference.signum() >= 0 ? fingerprint.rawFingerprint : rawFingerprint + let smallerFingerprint = sampleDifference.signum() >= 0 ? rawFingerprint : fingerprint.rawFingerprint + + if !ignoreLength && abs(sampleDifference) > biggerFingerprint.count / 2 { + throw Error.lenghtDifference + } + + var smallestError = Int.max + for offset in 0...abs(sampleDifference) { + var error = 0 + for (index, value) in smallerFingerprint.enumerated() { + error += (value ^ biggerFingerprint[index + offset]).nonzeroBitCount + } + smallestError = error < smallestError ? error : smallestError + } + + return 1 - Double(smallestError) / 32 / Double(smallerFingerprint.count) } } diff --git a/Tests/ChromaSwiftTests/ChromaSwiftTests.swift b/Tests/ChromaSwiftTests/ChromaSwiftTests.swift index 5da661f..10e8e73 100644 --- a/Tests/ChromaSwiftTests/ChromaSwiftTests.swift +++ b/Tests/ChromaSwiftTests/ChromaSwiftTests.swift @@ -77,7 +77,7 @@ class ChromaSwiftTests: XCTestCase { let result = try AudioFingerprint(from: backbeatURL, algorithm: .test4) XCTAssertEqual(result.algorithm, AudioFingerprint.Algorithm.test4) - let constructedResult = try AudioFingerprint(from: result.fingerprint!, duration: result.duration) + let constructedResult = try AudioFingerprint(from: result.fingerprint, duration: result.duration) XCTAssertEqual(constructedResult.algorithm, AudioFingerprint.Algorithm.test4) } @@ -89,31 +89,56 @@ class ChromaSwiftTests: XCTestCase { let longResult = try AudioFingerprint(from: fireworksURL, maxSampleDuration: nil) XCTAssertEqual(UInt(longResult.duration), 191) - XCTAssertGreaterThan(longResult.fingerprint!.count, result.fingerprint!.count) + XCTAssertGreaterThan(longResult.fingerprint.count, result.fingerprint.count) let shortResult = try AudioFingerprint(from: fireworksURL, maxSampleDuration: 10.0) XCTAssertEqual(UInt(shortResult.duration), 191) - XCTAssertLessThan(shortResult.fingerprint!.count, result.fingerprint!.count) + XCTAssertLessThan(shortResult.fingerprint.count, result.fingerprint.count) } func testSimilarity() throws { let result = try AudioFingerprint(from: backbeatURL) + let compareResult = try AudioFingerprint(from: backbeatFingerprint, duration: 46.0) + XCTAssertEqual(try result.similarity(to: result), 1.00) + XCTAssertGreaterThan(try result.similarity(to: compareResult), 0.99) let otherResult = try AudioFingerprint(from: fireworksURL) + let otherCompareResult = try AudioFingerprint(from: fireworksFingerprint, duration: 191.0) + XCTAssertEqual(try otherResult.similarity(to: otherResult), 1.00) + XCTAssertGreaterThan(try otherResult.similarity(to: otherCompareResult), 0.99) - XCTAssertEqual(result.similarity(to: otherResult), 0.78125) + XCTAssertLessThan(try result.similarity(to: otherResult, ignoreLength: true), 0.55) + } + + func testInvalidSimilarity() throws { + let result = try AudioFingerprint(from: backbeatFingerprint, duration: 46.0) + let resultTest4 = try AudioFingerprint(from: backbeatFingerprintTest4, duration: 46.0) + let otherResult = try AudioFingerprint(from: fireworksFingerprint, duration: 191.0) + + XCTAssertThrowsError(try result.similarity(to: otherResult)) { error in + XCTAssertEqual(error as? AudioFingerprint.Error, AudioFingerprint.Error.lenghtDifference) + } + + XCTAssertEqual(resultTest4.algorithm, AudioFingerprint.Algorithm.test4) + XCTAssertThrowsError(try result.similarity(to: resultTest4)) { error in + XCTAssertEqual(error as? AudioFingerprint.Error, AudioFingerprint.Error.differentAlgorithm) + } } func testHashSimilarity() throws { let result = try AudioFingerprint(from: backbeatURL) - XCTAssertEqual(result.similarity(to: backbeatHash), 1.0) + XCTAssertEqual(try result.similarity(to: backbeatHash), 1.0) + + XCTAssertEqual(try result.similarity(to: fireworksHash), 0.78125) } func testInvalidHashSimilarity() throws { - let result = try AudioFingerprint(from: backbeatURL) + let result = try AudioFingerprint(from: backbeatFingerprint, duration: 46.0) - XCTAssertNil(result.similarity(to: "Invalid")) + XCTAssertThrowsError(try result.similarity(to: "Invalid")) { error in + XCTAssertEqual(error as? AudioFingerprint.Error, AudioFingerprint.Error.invalidHash) + } } func testAcoustIDInvalidAPIKey() throws {