SilenceDetector.swift 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498
  1. import Foundation
  2. import AVFoundation
  3. /// Represents a detected silent interval within an audio recording.
  4. public struct SilenceRange: Codable, Hashable, Sendable {
  5. public let start: TimeInterval // in seconds
  6. public let end: TimeInterval // in seconds
  7. public var duration: TimeInterval {
  8. end - start
  9. }
  10. public init(start: TimeInterval, end: TimeInterval) {
  11. self.start = start
  12. self.end = end
  13. }
  14. }
  15. /// Playback analysis generated in one pass over the decoded audio.
  16. public struct AudioAnalysisResult: Sendable {
  17. public let waveformSamples: [Float]
  18. public let silentRanges: [SilenceRange]
  19. public init(waveformSamples: [Float], silentRanges: [SilenceRange]) {
  20. self.waveformSamples = waveformSamples
  21. self.silentRanges = silentRanges
  22. }
  23. }
  24. /// One progressively decoded section of a recording.
  25. public struct AudioAnalysisProgress: Sendable {
  26. public let totalSampleCount: Int
  27. public let sampleOffset: Int
  28. public let waveformSamples: [Float]
  29. public let silentRanges: [SilenceRange]
  30. public let isComplete: Bool
  31. }
  32. private struct AudioAnalysisCacheEntry: Codable {
  33. let version: Int
  34. let fileSize: Int64
  35. let modificationTime: TimeInterval
  36. let waveformSamples: [Float]
  37. let silentRanges: [SilenceRange]
  38. }
  39. /// A cancellable, priority-aware analysis run. The encoded recording stays as
  40. /// one file; only decoding is divided into logical 60-second sections.
  41. public final class AudioAnalysisSession: @unchecked Sendable {
  42. private static let cacheVersion = 1
  43. private static let sampleInterval: TimeInterval = 0.05
  44. private static let segmentDuration: TimeInterval = 60
  45. private let audioURL: URL
  46. private let silenceThresholdDB: Float
  47. private let minimumSilenceDuration: TimeInterval
  48. private let stateLock = NSLock()
  49. private var cancelled = false
  50. private var prioritizedTime: TimeInterval?
  51. public init(
  52. audioURL: URL,
  53. silenceThresholdDB: Float = -40,
  54. minimumSilenceDuration: TimeInterval = 2
  55. ) {
  56. self.audioURL = audioURL
  57. self.silenceThresholdDB = silenceThresholdDB
  58. self.minimumSilenceDuration = minimumSilenceDuration
  59. }
  60. public func cancel() {
  61. stateLock.lock()
  62. cancelled = true
  63. stateLock.unlock()
  64. }
  65. public func prioritize(time: TimeInterval) {
  66. stateLock.lock()
  67. prioritizedTime = max(0, time)
  68. stateLock.unlock()
  69. }
  70. public func run(
  71. onUpdate: @escaping @Sendable (AudioAnalysisProgress) async -> Void
  72. ) async {
  73. await Task.detached(priority: .utility) { [self] in
  74. await runDetached(onUpdate: onUpdate)
  75. }.value
  76. }
  77. private func runDetached(
  78. onUpdate: @escaping @Sendable (AudioAnalysisProgress) async -> Void
  79. ) async {
  80. guard let validURL = AudioPathHelper.resolveURL(for: audioURL.path),
  81. let identity = Self.fileIdentity(for: validURL) else {
  82. return
  83. }
  84. if usesDefaultCacheSettings,
  85. let cached = Self.loadCache(for: validURL, identity: identity) {
  86. await onUpdate(AudioAnalysisProgress(
  87. totalSampleCount: cached.waveformSamples.count,
  88. sampleOffset: 0,
  89. waveformSamples: cached.waveformSamples,
  90. silentRanges: cached.silentRanges,
  91. isComplete: true
  92. ))
  93. return
  94. }
  95. guard let audioFile = try? AVAudioFile(forReading: validURL) else { return }
  96. let format = audioFile.processingFormat
  97. let sampleRate = format.sampleRate
  98. guard sampleRate > 0 else { return }
  99. let subChunkFrames = max(1, Int64(sampleRate * Self.sampleInterval))
  100. let segmentFrames = max(subChunkFrames, Int64(sampleRate * Self.segmentDuration))
  101. let totalFrames = audioFile.length
  102. let totalSampleCount = Int((totalFrames + subChunkFrames - 1) / subChunkFrames)
  103. let totalSegmentCount = max(1, Int((totalFrames + segmentFrames - 1) / segmentFrames))
  104. var completedSegments = Set<Int>()
  105. var completeWaveform = Array(repeating: Float(0), count: totalSampleCount)
  106. var rawSilentRanges: [SilenceRange] = []
  107. while completedSegments.count < totalSegmentCount {
  108. if isCancelled || Task.isCancelled { return }
  109. let segmentIndex = nextSegmentIndex(
  110. totalSegmentCount: totalSegmentCount,
  111. completedSegments: completedSegments
  112. )
  113. guard let segmentIndex else { break }
  114. let startFrame = Int64(segmentIndex) * segmentFrames
  115. let endFrame = min(startFrame + segmentFrames, totalFrames)
  116. guard let segment = Self.analyzeSegment(
  117. audioFile: audioFile,
  118. format: format,
  119. sampleRate: sampleRate,
  120. startFrame: startFrame,
  121. endFrame: endFrame,
  122. subChunkFrames: subChunkFrames,
  123. silenceThresholdDB: silenceThresholdDB
  124. ) else {
  125. return
  126. }
  127. completedSegments.insert(segmentIndex)
  128. let sampleOffset = Int(startFrame / subChunkFrames)
  129. let upperBound = min(sampleOffset + segment.waveformSamples.count, completeWaveform.count)
  130. if sampleOffset < upperBound {
  131. completeWaveform.replaceSubrange(
  132. sampleOffset..<upperBound,
  133. with: segment.waveformSamples.prefix(upperBound - sampleOffset)
  134. )
  135. }
  136. rawSilentRanges.append(contentsOf: segment.rawSilentRanges)
  137. let mergedSilence = SilenceDetector.mergeSilentRanges(
  138. rawSilentRanges,
  139. minimumDuration: minimumSilenceDuration
  140. )
  141. let isComplete = completedSegments.count == totalSegmentCount
  142. await onUpdate(AudioAnalysisProgress(
  143. totalSampleCount: totalSampleCount,
  144. sampleOffset: sampleOffset,
  145. waveformSamples: segment.waveformSamples,
  146. silentRanges: mergedSilence,
  147. isComplete: isComplete
  148. ))
  149. await Task.yield()
  150. if isComplete, usesDefaultCacheSettings {
  151. Self.saveCache(
  152. AudioAnalysisCacheEntry(
  153. version: Self.cacheVersion,
  154. fileSize: identity.fileSize,
  155. modificationTime: identity.modificationTime,
  156. waveformSamples: completeWaveform,
  157. silentRanges: mergedSilence
  158. ),
  159. for: validURL
  160. )
  161. }
  162. }
  163. }
  164. private var isCancelled: Bool {
  165. stateLock.lock()
  166. defer { stateLock.unlock() }
  167. return cancelled
  168. }
  169. private var usesDefaultCacheSettings: Bool {
  170. silenceThresholdDB == -40 && minimumSilenceDuration == 2
  171. }
  172. private func nextSegmentIndex(
  173. totalSegmentCount: Int,
  174. completedSegments: Set<Int>
  175. ) -> Int? {
  176. stateLock.lock()
  177. let preferredTime = prioritizedTime
  178. prioritizedTime = nil
  179. stateLock.unlock()
  180. if let preferredTime {
  181. let preferredIndex = min(
  182. max(Int(preferredTime / Self.segmentDuration), 0),
  183. totalSegmentCount - 1
  184. )
  185. if !completedSegments.contains(preferredIndex) {
  186. return preferredIndex
  187. }
  188. // After the requested section, favor its immediate neighbors.
  189. for distance in 1..<totalSegmentCount {
  190. let forward = preferredIndex + distance
  191. if forward < totalSegmentCount, !completedSegments.contains(forward) {
  192. return forward
  193. }
  194. let backward = preferredIndex - distance
  195. if backward >= 0, !completedSegments.contains(backward) {
  196. return backward
  197. }
  198. }
  199. }
  200. return (0..<totalSegmentCount).first { !completedSegments.contains($0) }
  201. }
  202. private struct SegmentResult {
  203. let waveformSamples: [Float]
  204. let rawSilentRanges: [SilenceRange]
  205. }
  206. private static func analyzeSegment(
  207. audioFile: AVAudioFile,
  208. format: AVAudioFormat,
  209. sampleRate: Double,
  210. startFrame: Int64,
  211. endFrame: Int64,
  212. subChunkFrames: Int64,
  213. silenceThresholdDB: Float
  214. ) -> SegmentResult? {
  215. let oneSecondFrames = max(1, AVAudioFrameCount(sampleRate))
  216. guard let buffer = AVAudioPCMBuffer(
  217. pcmFormat: format,
  218. frameCapacity: oneSecondFrames
  219. ) else {
  220. return nil
  221. }
  222. audioFile.framePosition = startFrame
  223. var waveformSamples: [Float] = []
  224. waveformSamples.reserveCapacity(Int((endFrame - startFrame + subChunkFrames - 1) / subChunkFrames))
  225. var rawSilentRanges: [SilenceRange] = []
  226. var silenceStart: TimeInterval?
  227. do {
  228. while audioFile.framePosition < endFrame {
  229. if Task.isCancelled { return nil }
  230. let remainingFrames = endFrame - audioFile.framePosition
  231. let framesToRead = AVAudioFrameCount(
  232. min(Int64(oneSecondFrames), remainingFrames)
  233. )
  234. guard framesToRead > 0 else { break }
  235. try audioFile.read(into: buffer, frameCount: framesToRead)
  236. guard let channelData = buffer.floatChannelData?[0] else { continue }
  237. let bufferStartFrame = audioFile.framePosition - Int64(buffer.frameLength)
  238. var offset = 0
  239. while offset < Int(buffer.frameLength) {
  240. let sampleCount = min(Int(subChunkFrames), Int(buffer.frameLength) - offset)
  241. guard sampleCount > 0 else { break }
  242. var sum: Float = 0
  243. for index in 0..<sampleCount {
  244. let sample = channelData[offset + index]
  245. sum += sample * sample
  246. }
  247. let rms = sqrt(sum / Float(sampleCount))
  248. let decibels = rms > 0 ? 20 * log10(rms) : -100
  249. waveformSamples.append(
  250. AudioLevelNormalizer.normalizedLevel(decibels: decibels)
  251. )
  252. let absoluteFrame = bufferStartFrame + Int64(offset)
  253. let time = Double(absoluteFrame) / sampleRate
  254. if decibels < silenceThresholdDB {
  255. if silenceStart == nil {
  256. silenceStart = time
  257. }
  258. } else if let start = silenceStart {
  259. rawSilentRanges.append(SilenceRange(start: start, end: time))
  260. silenceStart = nil
  261. }
  262. offset += sampleCount
  263. }
  264. }
  265. } catch {
  266. print("[SilenceDetector] Error reading audio segment: \(error.localizedDescription)")
  267. return nil
  268. }
  269. if let start = silenceStart {
  270. rawSilentRanges.append(
  271. SilenceRange(start: start, end: Double(endFrame) / sampleRate)
  272. )
  273. }
  274. return SegmentResult(
  275. waveformSamples: waveformSamples,
  276. rawSilentRanges: rawSilentRanges
  277. )
  278. }
  279. private struct FileIdentity {
  280. let fileSize: Int64
  281. let modificationTime: TimeInterval
  282. }
  283. private static func fileIdentity(for url: URL) -> FileIdentity? {
  284. guard let values = try? url.resourceValues(
  285. forKeys: [.fileSizeKey, .contentModificationDateKey]
  286. ) else {
  287. return nil
  288. }
  289. return FileIdentity(
  290. fileSize: Int64(values.fileSize ?? 0),
  291. modificationTime: values.contentModificationDate?.timeIntervalSince1970 ?? 0
  292. )
  293. }
  294. private static func cacheURL(for audioURL: URL) -> URL? {
  295. guard let cacheRoot = FileManager.default.urls(
  296. for: .cachesDirectory,
  297. in: .userDomainMask
  298. ).first else {
  299. return nil
  300. }
  301. let directory = cacheRoot
  302. .appendingPathComponent("CelestiaTrace", isDirectory: true)
  303. .appendingPathComponent("AudioAnalysis", isDirectory: true)
  304. try? FileManager.default.createDirectory(
  305. at: directory,
  306. withIntermediateDirectories: true
  307. )
  308. var hash: UInt64 = 1_469_598_103_934_665_603
  309. // Persist across app-container path changes by keying on the stable
  310. // recording filename; size and modification time validate the content.
  311. for byte in audioURL.lastPathComponent.utf8 {
  312. hash ^= UInt64(byte)
  313. hash &*= 1_099_511_628_211
  314. }
  315. return directory.appendingPathComponent(String(hash, radix: 16) + ".plist")
  316. }
  317. private static func loadCache(
  318. for audioURL: URL,
  319. identity: FileIdentity
  320. ) -> AudioAnalysisCacheEntry? {
  321. guard let cacheURL = cacheURL(for: audioURL),
  322. let data = try? Data(contentsOf: cacheURL),
  323. let entry = try? PropertyListDecoder().decode(
  324. AudioAnalysisCacheEntry.self,
  325. from: data
  326. ),
  327. entry.version == cacheVersion,
  328. entry.fileSize == identity.fileSize,
  329. abs(entry.modificationTime - identity.modificationTime) < 0.001 else {
  330. return nil
  331. }
  332. return entry
  333. }
  334. private static func saveCache(
  335. _ entry: AudioAnalysisCacheEntry,
  336. for audioURL: URL
  337. ) {
  338. guard let cacheURL = cacheURL(for: audioURL) else { return }
  339. let encoder = PropertyListEncoder()
  340. encoder.outputFormat = .binary
  341. guard let data = try? encoder.encode(entry) else { return }
  342. try? data.write(to: cacheURL, options: .atomic)
  343. }
  344. }
  345. private actor AudioAnalysisCollector {
  346. private var waveformSamples: [Float] = []
  347. private var silentRanges: [SilenceRange] = []
  348. func apply(_ update: AudioAnalysisProgress) {
  349. if waveformSamples.count != update.totalSampleCount {
  350. waveformSamples = Array(repeating: 0, count: update.totalSampleCount)
  351. }
  352. let lowerBound = min(max(update.sampleOffset, 0), waveformSamples.count)
  353. let upperBound = min(lowerBound + update.waveformSamples.count, waveformSamples.count)
  354. if lowerBound < upperBound {
  355. waveformSamples.replaceSubrange(
  356. lowerBound..<upperBound,
  357. with: update.waveformSamples.prefix(upperBound - lowerBound)
  358. )
  359. }
  360. silentRanges = update.silentRanges
  361. }
  362. func result() -> AudioAnalysisResult {
  363. AudioAnalysisResult(
  364. waveformSamples: waveformSamples,
  365. silentRanges: silentRanges
  366. )
  367. }
  368. }
  369. public final class SilenceDetector: Sendable {
  370. /// Detects silent ranges in the specified audio file.
  371. /// - Parameters:
  372. /// - audioURL: Local file URL of the audio.
  373. /// - thresholdDB: Threshold in decibels (e.g. -40.0 dB). Sounds below this are considered silent.
  374. /// - minDuration: Minimum consecutive duration in seconds to qualify as a silent segment.
  375. /// - Returns: An array of detected SilenceRange objects.
  376. public static func detectSilence(
  377. in audioURL: URL,
  378. thresholdDB: Float = -40.0,
  379. minDuration: TimeInterval = 2.0
  380. ) async -> [SilenceRange] {
  381. await analyze(
  382. audioURL,
  383. silenceThresholdDB: thresholdDB,
  384. minimumSilenceDuration: minDuration
  385. ).silentRanges
  386. }
  387. /// Extracts waveform levels and silence ranges together to avoid decoding twice.
  388. /// Waveform samples use the same 50ms cadence and dB normalization as live recording.
  389. public static func analyze(
  390. _ audioURL: URL,
  391. silenceThresholdDB: Float = -40.0,
  392. minimumSilenceDuration: TimeInterval = 2.0
  393. ) async -> AudioAnalysisResult {
  394. let collector = AudioAnalysisCollector()
  395. let session = AudioAnalysisSession(
  396. audioURL: audioURL,
  397. silenceThresholdDB: silenceThresholdDB,
  398. minimumSilenceDuration: minimumSilenceDuration
  399. )
  400. await withTaskCancellationHandler {
  401. await session.run { update in
  402. await collector.apply(update)
  403. }
  404. } onCancel: {
  405. session.cancel()
  406. }
  407. return await collector.result()
  408. }
  409. /// Warms the derived cache without retaining waveform data in a view model.
  410. public static func warmCache(for audioURL: URL) async {
  411. let session = AudioAnalysisSession(audioURL: audioURL)
  412. await withTaskCancellationHandler {
  413. await session.run { _ in }
  414. } onCancel: {
  415. session.cancel()
  416. }
  417. }
  418. /// Merges silence that crosses a logical segment boundary, then applies the
  419. /// product's two-second minimum. Kept internal for focused logic tests.
  420. static func mergeSilentRanges(
  421. _ ranges: [SilenceRange],
  422. minimumDuration: TimeInterval = 2
  423. ) -> [SilenceRange] {
  424. let sorted = ranges.sorted {
  425. if $0.start == $1.start {
  426. return $0.end < $1.end
  427. }
  428. return $0.start < $1.start
  429. }
  430. guard var current = sorted.first else { return [] }
  431. var merged: [SilenceRange] = []
  432. for next in sorted.dropFirst() {
  433. if next.start <= current.end + 0.001 {
  434. current = SilenceRange(
  435. start: current.start,
  436. end: max(current.end, next.end)
  437. )
  438. } else {
  439. if current.duration >= minimumDuration {
  440. merged.append(current)
  441. }
  442. current = next
  443. }
  444. }
  445. if current.duration >= minimumDuration {
  446. merged.append(current)
  447. }
  448. return merged
  449. }
  450. }