| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165 |
- 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..<currentSubChunkSize {
- let sample = channelData[offset + i]
- sum += sample * sample
- }
-
- let rms = sqrt(sum / Float(currentSubChunkSize))
- let db = rms > 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
- }
- }
|