Module: Cohere::Transcribe::Audio::Segmentation
- Defined in:
- lib/cohere/transcribe/audio/segmentation.rb
Class Method Summary collapse
-
.energy(audio, min_duration:, max_duration:, max_silence:, threshold:, analysis_window: 0.05) ⇒ Object
Auditok-equivalent energy segmentation.
- .fixed(audio_or_length, window_seconds) ⇒ Object
- .merge_speech(segments, max_duration) ⇒ Object
- .samples_to_seconds(timestamps, audio_samples) ⇒ Object
- .validate(segments, duration, max_duration: nil) ⇒ Object
Class Method Details
.energy(audio, min_duration:, max_duration:, max_silence:, threshold:, analysis_window: 0.05) ⇒ Object
Auditok-equivalent energy segmentation. Auditok evaluates 50 ms PCM16 windows with 20*log10(RMS), tolerates internal silence, and cuts at a hard maximum duration. Keeping this native avoids a Python dependency.
113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 |
# File 'lib/cohere/transcribe/audio/segmentation.rb', line 113 def energy(audio, min_duration:, max_duration:, max_silence:, threshold:, analysis_window: 0.05) unless real_finite?(analysis_window) && analysis_window.positive? raise ArgumentError, "analysis_window must be finite and positive" end raise ArgumentError, "min_duration must be finite and positive" unless real_finite?(min_duration) && min_duration.positive? raise ArgumentError, "max_duration must be finite and positive" unless real_finite?(max_duration) && max_duration.positive? raise ArgumentError, "max_silence must be finite and non-negative" unless real_finite?(max_silence) && max_silence >= 0 raise ArgumentError, "threshold must be finite" unless real_finite?(threshold) window_samples = (analysis_window * SAMPLE_RATE).to_i raise ArgumentError, "analysis_window must cover at least one audio sample" if window_samples.zero? frame_seconds = window_samples.fdiv(SAMPLE_RATE) minimum_frames = (min_duration / frame_seconds).ceil maximum_frames = ((max_duration / frame_seconds) + 1e-10).floor silence_frames = ((max_silence / frame_seconds) + 1e-10).floor raise ArgumentError, "max_duration must cover min_duration" if maximum_frames < minimum_frames raise ArgumentError, "max_silence must cover fewer analysis windows than max_duration" if silence_frames >= maximum_frames active = [] frame_lengths = [] (0...audio.length).step(window_samples) do |offset| finish = [offset + window_samples, audio.length].min count = finish - offset # The Python path multiplies decoded float32 samples by 32767 in # NumPy before converting them to PCM16. Round the products back # to IEEE-754 binary32 as one block so values just below an integer # PCM boundary behave identically (for example, 3 / 32767). scaled_pcm = Array.new(count) do |relative_index| Float(audio[offset + relative_index]).clamp(-1.0, 1.0) * 32_767.0 end sum_squares = scaled_pcm.pack("e*").unpack("e*").sum do |value| pcm = value.to_i pcm * pcm end rms = Math.sqrt(sum_squares.fdiv([count, 1].max)) energy_db = 20.0 * Math.log10([rms, 1e-10].max) active << (energy_db >= threshold) frame_lengths << count end tokenize_energy_frames( active, frame_lengths, minimum_frames, maximum_frames, silence_frames, frame_seconds ) end |
.fixed(audio_or_length, window_seconds) ⇒ Object
11 12 13 14 15 16 17 18 19 20 21 |
# File 'lib/cohere/transcribe/audio/segmentation.rb', line 11 def fixed(audio_or_length, window_seconds) total_samples = audio_or_length.is_a?(Integer) ? audio_or_length : audio_or_length.length return [] if total_samples.zero? raise ArgumentError, "audio length must be non-negative" if total_samples.negative? raise ArgumentError, "window_seconds must be finite and positive" unless real_finite?(window_seconds) && window_seconds.positive? window_samples = [1, (window_seconds * SAMPLE_RATE).round(half: :even)].max (0...total_samples).step(window_samples).map do |start_sample| [start_sample.fdiv(SAMPLE_RATE), [start_sample + window_samples, total_samples].min.fdiv(SAMPLE_RATE)] end end |
.merge_speech(segments, max_duration) ⇒ Object
74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 |
# File 'lib/cohere/transcribe/audio/segmentation.rb', line 74 def merge_speech(segments, max_duration) raise ArgumentError, "max_duration must be finite and positive" unless real_finite?(max_duration) && max_duration.positive? return [] if segments.empty? first = segment_bounds(segments.first, 0) current_start, current_end = first.map { |value| Float(value) } if current_start.negative? || current_end < current_start raise ArgumentError, "VAD segments must have non-negative ordered bounds" end merged = [] segments.drop(1).each_with_index do |segment, offset| raw_start, raw_end = segment_bounds(segment, offset + 1) start_time = Float(raw_start) end_time = Float(raw_end) raise ArgumentError, "VAD segments must be sorted and non-overlapping" if start_time < current_end || end_time < start_time if end_time - current_start <= max_duration + 1e-9 current_end = end_time else merged << [current_start, current_end] current_start = start_time current_end = end_time end end merged << [current_start, current_end] end |
.samples_to_seconds(timestamps, audio_samples) ⇒ Object
62 63 64 65 66 67 68 69 70 71 72 |
# File 'lib/cohere/transcribe/audio/segmentation.rb', line 62 def samples_to_seconds(, audio_samples) duration = audio_samples.fdiv(SAMPLE_RATE) segments = .filter_map do |item| start_sample = fetch(item, :start) end_sample = fetch(item, :end) start_time = [0.0, Float(start_sample).fdiv(SAMPLE_RATE)].max end_time = [duration, Float(end_sample).fdiv(SAMPLE_RATE)].min [start_time, end_time] if end_time > start_time end validate(segments, duration) end |
.validate(segments, duration, max_duration: nil) ⇒ Object
23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 |
# File 'lib/cohere/transcribe/audio/segmentation.rb', line 23 def validate(segments, duration, max_duration: nil) raise ArgumentError, "Audio duration must be finite and non-negative" unless real_finite?(duration) && duration >= 0 tolerance = 2.0 / SAMPLE_RATE previous_end = 0.0 segments.each_with_index.filter_map do |segment, index| row = segment.respond_to?(:to_ary) ? segment.to_ary : nil raise ArgumentError, "Segment #{index} must contain exactly two bounds" unless row.is_a?(Array) && row.length == 2 raw_start, raw_end = row start_time = Float(raw_start) end_time = Float(raw_end) raise ArgumentError, "Segment #{index} has non-finite bounds" unless start_time.finite? && end_time.finite? if start_time < -tolerance || end_time > duration + tolerance raise ArgumentError, format("Segment %<index>d lies outside the audio: %<start>.6f..%<end>.6f for %<duration>.6fs", index: index, start: start_time, end: end_time, duration: duration) end start_time = start_time.clamp(0.0, duration) end_time = end_time.clamp(0.0, duration) next if end_time <= start_time raise ArgumentError, "Segment #{index} overlaps or is out of order" if start_time < previous_end - tolerance start_time = previous_end if start_time < previous_end if max_duration && end_time - start_time > max_duration + tolerance raise ArgumentError, format("Segment %<index>d is %<duration>.6fs, exceeding the %<max>gs single-row limit", index: index, duration: end_time - start_time, max: max_duration) end previous_end = end_time [start_time, end_time] rescue TypeError, ArgumentError => e raise e if e..start_with?("Segment ") raise ArgumentError, "Segment #{index} has invalid bounds: #{e.}" end end |