SilenceDetector.swift 5.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124
  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. public final class SilenceDetector: Sendable {
  16. /// Detects silent ranges in the specified audio file.
  17. /// - Parameters:
  18. /// - audioURL: Local file URL of the audio.
  19. /// - thresholdDB: Threshold in decibels (e.g. -40.0 dB). Sounds below this are considered silent.
  20. /// - minDuration: Minimum consecutive duration in seconds to qualify as a silent segment.
  21. /// - Returns: An array of detected SilenceRange objects.
  22. public static func detectSilence(
  23. in audioURL: URL,
  24. thresholdDB: Float = -40.0,
  25. minDuration: TimeInterval = 2.0
  26. ) async -> [SilenceRange] {
  27. guard let validURL = AudioPathHelper.resolveURL(for: audioURL.path) else {
  28. print("[SilenceDetector] File not found or empty path: \(audioURL.path).")
  29. return []
  30. }
  31. let task = Task.detached(priority: .userInitiated) { () -> [SilenceRange] in
  32. guard let audioFile = try? AVAudioFile(forReading: validURL) else {
  33. return []
  34. }
  35. let format = audioFile.processingFormat
  36. let sampleRate = format.sampleRate
  37. guard sampleRate > 0 else { return [] }
  38. // Buffer size of ~1.0 second
  39. let bufferSize = AVAudioFrameCount(sampleRate)
  40. guard let buffer = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: bufferSize) else {
  41. return []
  42. }
  43. var silentRanges: [SilenceRange] = []
  44. var isSilent = false
  45. var silenceStart: TimeInterval = 0
  46. // Sub-chunk size of 100ms
  47. let subChunkSize = Int(sampleRate * 0.1)
  48. do {
  49. while audioFile.framePosition < audioFile.length {
  50. let framesToRead = min(bufferSize, AVAudioFrameCount(audioFile.length - audioFile.framePosition))
  51. if framesToRead <= 0 { break }
  52. try audioFile.read(into: buffer, frameCount: framesToRead)
  53. guard let floatChannelData = buffer.floatChannelData else { continue }
  54. let channelData = floatChannelData[0]
  55. let frameLength = Int(buffer.frameLength)
  56. var offset = 0
  57. while offset < frameLength {
  58. let currentSubChunkSize = min(subChunkSize, frameLength - offset)
  59. if currentSubChunkSize <= 0 { break }
  60. var sum: Float = 0
  61. for i in 0..<currentSubChunkSize {
  62. let sample = channelData[offset + i]
  63. sum += sample * sample
  64. }
  65. let rms = sqrt(sum / Float(currentSubChunkSize))
  66. let db = rms > 0 ? 20 * log10(rms) : -100.0
  67. // Calculate absolute time of this sub-chunk
  68. let absoluteFramePosition = audioFile.framePosition - Int64(frameLength) + Int64(offset)
  69. let timeInSeconds = Double(absoluteFramePosition) / sampleRate
  70. let isChunkSilent = db < thresholdDB
  71. if isChunkSilent {
  72. if !isSilent {
  73. isSilent = true
  74. silenceStart = timeInSeconds
  75. }
  76. } else {
  77. if isSilent {
  78. isSilent = false
  79. let silenceEnd = timeInSeconds
  80. if silenceEnd - silenceStart >= minDuration {
  81. silentRanges.append(SilenceRange(start: silenceStart, end: silenceEnd))
  82. }
  83. }
  84. }
  85. offset += subChunkSize
  86. }
  87. }
  88. // If still silent at the end of the file
  89. if isSilent {
  90. let silenceEnd = Double(audioFile.length) / sampleRate
  91. if silenceEnd - silenceStart >= minDuration {
  92. silentRanges.append(SilenceRange(start: silenceStart, end: silenceEnd))
  93. }
  94. }
  95. } catch {
  96. print("[SilenceDetector] Error reading audio file: \(error.localizedDescription)")
  97. }
  98. print("[SilenceDetector] Detection complete. Found \(silentRanges.count) silence ranges.")
  99. return silentRanges
  100. }
  101. return await task.value
  102. }
  103. }