import Foundation import AVFoundation /// Represents a detected silent interval within an audio recording. public struct SilenceRange: Codable, Hashable, Sendable { public let start: TimeInterval // in seconds public let end: TimeInterval // in seconds public var duration: TimeInterval { end - start } public init(start: TimeInterval, end: TimeInterval) { self.start = start self.end = end } } public final class SilenceDetector: Sendable { /// Detects silent ranges in the specified audio file. /// - Parameters: /// - audioURL: Local file URL of the audio. /// - thresholdDB: Threshold in decibels (e.g. -40.0 dB). Sounds below this are considered silent. /// - minDuration: Minimum consecutive duration in seconds to qualify as a silent segment. /// - Returns: An array of detected SilenceRange objects. public static func detectSilence( in audioURL: URL, thresholdDB: Float = -40.0, minDuration: TimeInterval = 2.0 ) async -> [SilenceRange] { let fm = FileManager.default // Fallback to mock data if the file doesn't exist locally (for simulator and mock prototyping) guard !audioURL.path.isEmpty && fm.fileExists(atPath: audioURL.path) else { print("[SilenceDetector] File not found or empty path: \(audioURL.path). Returning mock silence ranges.") return [ SilenceRange(start: 5.0, end: 12.0), SilenceRange(start: 25.0, end: 32.0) ] } let task = Task.detached(priority: .userInitiated) { () -> [SilenceRange] in guard let audioFile = try? AVAudioFile(forReading: audioURL) else { return [] } let format = audioFile.processingFormat let sampleRate = format.sampleRate guard sampleRate > 0 else { return [] } // Buffer size of ~1.0 second let bufferSize = AVAudioFrameCount(sampleRate) guard let buffer = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: bufferSize) else { return [] } var silentRanges: [SilenceRange] = [] var isSilent = false var silenceStart: TimeInterval = 0 // Sub-chunk size of 100ms let subChunkSize = Int(sampleRate * 0.1) do { while audioFile.framePosition < audioFile.length { let framesToRead = min(bufferSize, AVAudioFrameCount(audioFile.length - audioFile.framePosition)) if framesToRead <= 0 { break } try audioFile.read(into: buffer, frameCount: framesToRead) guard let floatChannelData = buffer.floatChannelData else { continue } let channelData = floatChannelData[0] let frameLength = Int(buffer.frameLength) var offset = 0 while offset < frameLength { let currentSubChunkSize = min(subChunkSize, frameLength - offset) if currentSubChunkSize <= 0 { break } var sum: Float = 0 for i in 0.. 0 ? 20 * log10(rms) : -100.0 // Calculate absolute time of this sub-chunk let absoluteFramePosition = audioFile.framePosition - Int64(frameLength) + Int64(offset) let timeInSeconds = Double(absoluteFramePosition) / sampleRate let isChunkSilent = db < thresholdDB if isChunkSilent { if !isSilent { isSilent = true silenceStart = timeInSeconds } } else { if isSilent { isSilent = false let silenceEnd = timeInSeconds if silenceEnd - silenceStart >= minDuration { silentRanges.append(SilenceRange(start: silenceStart, end: silenceEnd)) } } } offset += subChunkSize } } // If still silent at the end of the file if isSilent { let silenceEnd = Double(audioFile.length) / sampleRate if silenceEnd - silenceStart >= minDuration { silentRanges.append(SilenceRange(start: silenceStart, end: silenceEnd)) } } } catch { print("[SilenceDetector] Error reading audio file: \(error.localizedDescription)") } print("[SilenceDetector] Detection complete. Found \(silentRanges.count) silence ranges.") return silentRanges } return await task.value } }