| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128 |
- 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] {
- // Fallback to mock data if the file doesn't exist locally (for simulator and mock prototyping)
- guard let validURL = AudioPathHelper.resolveURL(for: 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: validURL) 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..<currentSubChunkSize {
- let sample = channelData[offset + i]
- sum += sample * sample
- }
-
- let rms = sqrt(sum / Float(currentSubChunkSize))
- let db = rms > 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
- }
- }
|