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 } } /// Playback analysis generated in one pass over the decoded audio. public struct AudioAnalysisResult: Sendable { public let waveformSamples: [Float] public let silentRanges: [SilenceRange] public init(waveformSamples: [Float], silentRanges: [SilenceRange]) { self.waveformSamples = waveformSamples self.silentRanges = silentRanges } } 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] { await analyze( audioURL, silenceThresholdDB: thresholdDB, minimumSilenceDuration: minDuration ).silentRanges } /// Extracts waveform levels and silence ranges together to avoid decoding twice. /// Waveform samples use the same 50ms cadence and dB normalization as live recording. public static func analyze( _ audioURL: URL, silenceThresholdDB: Float = -40.0, minimumSilenceDuration: TimeInterval = 2.0 ) async -> AudioAnalysisResult { guard let validURL = AudioPathHelper.resolveURL(for: audioURL.path) else { print("[SilenceDetector] File not found or empty path: \(audioURL.path).") return AudioAnalysisResult(waveformSamples: [], silentRanges: []) } let task = Task.detached(priority: .userInitiated) { () -> AudioAnalysisResult in guard let audioFile = try? AVAudioFile(forReading: validURL) else { return AudioAnalysisResult(waveformSamples: [], silentRanges: []) } let format = audioFile.processingFormat let sampleRate = format.sampleRate guard sampleRate > 0 else { return AudioAnalysisResult(waveformSamples: [], silentRanges: []) } // Buffer size of ~1.0 second let bufferSize = AVAudioFrameCount(sampleRate) guard let buffer = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: bufferSize) else { return AudioAnalysisResult(waveformSamples: [], silentRanges: []) } var waveformSamples: [Float] = [] waveformSamples.reserveCapacity(Int(Double(audioFile.length) / sampleRate / 0.05) + 1) var silentRanges: [SilenceRange] = [] var isSilent = false var silenceStart: TimeInterval = 0 // Match RealAudioRecorder's 50ms metering cadence. let subChunkSize = max(1, Int(sampleRate * 0.05)) do { while audioFile.framePosition < audioFile.length { if Task.isCancelled { return AudioAnalysisResult(waveformSamples: [], silentRanges: []) } 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 waveformSamples.append( AudioLevelNormalizer.normalizedLevel(decibels: db) ) // Calculate absolute time of this sub-chunk let absoluteFramePosition = audioFile.framePosition - Int64(frameLength) + Int64(offset) let timeInSeconds = Double(absoluteFramePosition) / sampleRate let isChunkSilent = db < silenceThresholdDB if isChunkSilent { if !isSilent { isSilent = true silenceStart = timeInSeconds } } else { if isSilent { isSilent = false let silenceEnd = timeInSeconds if silenceEnd - silenceStart >= minimumSilenceDuration { 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 >= minimumSilenceDuration { silentRanges.append(SilenceRange(start: silenceStart, end: silenceEnd)) } } } catch { print("[SilenceDetector] Error reading audio file: \(error.localizedDescription)") } print( "[SilenceDetector] Analysis complete. Generated \(waveformSamples.count) waveform samples and found \(silentRanges.count) silence ranges." ) return AudioAnalysisResult( waveformSamples: waveformSamples, silentRanges: silentRanges ) } return await task.value } }