SilenceDetector.swift 7.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165
  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. public final class SilenceDetector: Sendable {
  25. /// Detects silent ranges in the specified audio file.
  26. /// - Parameters:
  27. /// - audioURL: Local file URL of the audio.
  28. /// - thresholdDB: Threshold in decibels (e.g. -40.0 dB). Sounds below this are considered silent.
  29. /// - minDuration: Minimum consecutive duration in seconds to qualify as a silent segment.
  30. /// - Returns: An array of detected SilenceRange objects.
  31. public static func detectSilence(
  32. in audioURL: URL,
  33. thresholdDB: Float = -40.0,
  34. minDuration: TimeInterval = 2.0
  35. ) async -> [SilenceRange] {
  36. await analyze(
  37. audioURL,
  38. silenceThresholdDB: thresholdDB,
  39. minimumSilenceDuration: minDuration
  40. ).silentRanges
  41. }
  42. /// Extracts waveform levels and silence ranges together to avoid decoding twice.
  43. /// Waveform samples use the same 50ms cadence and dB normalization as live recording.
  44. public static func analyze(
  45. _ audioURL: URL,
  46. silenceThresholdDB: Float = -40.0,
  47. minimumSilenceDuration: TimeInterval = 2.0
  48. ) async -> AudioAnalysisResult {
  49. guard let validURL = AudioPathHelper.resolveURL(for: audioURL.path) else {
  50. print("[SilenceDetector] File not found or empty path: \(audioURL.path).")
  51. return AudioAnalysisResult(waveformSamples: [], silentRanges: [])
  52. }
  53. let task = Task.detached(priority: .userInitiated) { () -> AudioAnalysisResult in
  54. guard let audioFile = try? AVAudioFile(forReading: validURL) else {
  55. return AudioAnalysisResult(waveformSamples: [], silentRanges: [])
  56. }
  57. let format = audioFile.processingFormat
  58. let sampleRate = format.sampleRate
  59. guard sampleRate > 0 else {
  60. return AudioAnalysisResult(waveformSamples: [], silentRanges: [])
  61. }
  62. // Buffer size of ~1.0 second
  63. let bufferSize = AVAudioFrameCount(sampleRate)
  64. guard let buffer = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: bufferSize) else {
  65. return AudioAnalysisResult(waveformSamples: [], silentRanges: [])
  66. }
  67. var waveformSamples: [Float] = []
  68. waveformSamples.reserveCapacity(Int(Double(audioFile.length) / sampleRate / 0.05) + 1)
  69. var silentRanges: [SilenceRange] = []
  70. var isSilent = false
  71. var silenceStart: TimeInterval = 0
  72. // Match RealAudioRecorder's 50ms metering cadence.
  73. let subChunkSize = max(1, Int(sampleRate * 0.05))
  74. do {
  75. while audioFile.framePosition < audioFile.length {
  76. if Task.isCancelled {
  77. return AudioAnalysisResult(waveformSamples: [], silentRanges: [])
  78. }
  79. let framesToRead = min(bufferSize, AVAudioFrameCount(audioFile.length - audioFile.framePosition))
  80. if framesToRead <= 0 { break }
  81. try audioFile.read(into: buffer, frameCount: framesToRead)
  82. guard let floatChannelData = buffer.floatChannelData else { continue }
  83. let channelData = floatChannelData[0]
  84. let frameLength = Int(buffer.frameLength)
  85. var offset = 0
  86. while offset < frameLength {
  87. let currentSubChunkSize = min(subChunkSize, frameLength - offset)
  88. if currentSubChunkSize <= 0 { break }
  89. var sum: Float = 0
  90. for i in 0..<currentSubChunkSize {
  91. let sample = channelData[offset + i]
  92. sum += sample * sample
  93. }
  94. let rms = sqrt(sum / Float(currentSubChunkSize))
  95. let db = rms > 0 ? 20 * log10(rms) : -100.0
  96. waveformSamples.append(
  97. AudioLevelNormalizer.normalizedLevel(decibels: db)
  98. )
  99. // Calculate absolute time of this sub-chunk
  100. let absoluteFramePosition = audioFile.framePosition - Int64(frameLength) + Int64(offset)
  101. let timeInSeconds = Double(absoluteFramePosition) / sampleRate
  102. let isChunkSilent = db < silenceThresholdDB
  103. if isChunkSilent {
  104. if !isSilent {
  105. isSilent = true
  106. silenceStart = timeInSeconds
  107. }
  108. } else {
  109. if isSilent {
  110. isSilent = false
  111. let silenceEnd = timeInSeconds
  112. if silenceEnd - silenceStart >= minimumSilenceDuration {
  113. silentRanges.append(SilenceRange(start: silenceStart, end: silenceEnd))
  114. }
  115. }
  116. }
  117. offset += subChunkSize
  118. }
  119. }
  120. // If still silent at the end of the file
  121. if isSilent {
  122. let silenceEnd = Double(audioFile.length) / sampleRate
  123. if silenceEnd - silenceStart >= minimumSilenceDuration {
  124. silentRanges.append(SilenceRange(start: silenceStart, end: silenceEnd))
  125. }
  126. }
  127. } catch {
  128. print("[SilenceDetector] Error reading audio file: \(error.localizedDescription)")
  129. }
  130. print(
  131. "[SilenceDetector] Analysis complete. Generated \(waveformSamples.count) waveform samples and found \(silentRanges.count) silence ranges."
  132. )
  133. return AudioAnalysisResult(
  134. waveformSamples: waveformSamples,
  135. silentRanges: silentRanges
  136. )
  137. }
  138. return await task.value
  139. }
  140. }