cohere-transcribe 0.1.2 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +9 -0
  3. data/README.md +9 -7
  4. data/ext/cohere_transcribe_native/.gitignore +6 -0
  5. data/ext/cohere_transcribe_native/CMakeLists.txt +8 -0
  6. data/ext/cohere_transcribe_native/audio_abi.cpp +20 -0
  7. data/ext/cohere_transcribe_native/audio_exports.macos +1 -0
  8. data/ext/cohere_transcribe_native/audio_exports.map +1 -0
  9. data/ext/cohere_transcribe_native/test/abi_smoke.rb +26 -0
  10. data/lib/cohere/transcribe/alignment/aligner.rb +40 -7
  11. data/lib/cohere/transcribe/asr/native.rb +43 -97
  12. data/lib/cohere/transcribe/audio/decoder.rb +184 -75
  13. data/lib/cohere/transcribe/audio/ffmpeg_native.rb +84 -32
  14. data/lib/cohere/transcribe/audio/segmentation.rb +1 -0
  15. data/lib/cohere/transcribe/cli.rb +30 -22
  16. data/lib/cohere/transcribe/configuration.rb +13 -0
  17. data/lib/cohere/transcribe/constants.rb +1 -1
  18. data/lib/cohere/transcribe/doctor.rb +2 -1
  19. data/lib/cohere/transcribe/hub.rb +446 -58
  20. data/lib/cohere/transcribe/input.rb +18 -3
  21. data/lib/cohere/transcribe/internal/interruptible_native_call.rb +70 -0
  22. data/lib/cohere/transcribe/internal/session_ownership.rb +61 -0
  23. data/lib/cohere/transcribe/internal/utf8.rb +19 -0
  24. data/lib/cohere/transcribe/model_identity.rb +10 -1
  25. data/lib/cohere/transcribe/output/publication.rb +95 -86
  26. data/lib/cohere/transcribe/pytorch_checkpoint.rb +38 -22
  27. data/lib/cohere/transcribe/runtime/engine.rb +37 -16
  28. data/lib/cohere/transcribe/runtime/preparation.rb +212 -35
  29. data/lib/cohere/transcribe/runtime/resources.rb +28 -62
  30. data/lib/cohere/transcribe/state/checkpoint.rb +3 -2
  31. data/lib/cohere/transcribe/state/io.rb +217 -117
  32. data/lib/cohere/transcribe/state/locking.rb +383 -64
  33. data/lib/cohere/transcribe/state/manifest.rb +21 -22
  34. data/lib/cohere/transcribe/types.rb +25 -11
  35. data/lib/cohere/transcribe/version.rb +1 -1
  36. data/sig/cohere/transcribe.rbs +1 -0
  37. metadata +5 -1
@@ -63,6 +63,7 @@ module Cohere
63
63
  ])
64
64
  extern "void* sf_open(const char*, int, void*)"
65
65
  extern "long long sf_readf_float(void*, void*, long long)"
66
+ extern "int sf_command(void*, int, void*, int)"
66
67
  extern "int sf_close(void*)"
67
68
  extern "const char* sf_strerror(void*)"
68
69
  AVAILABLE = true
@@ -115,8 +116,70 @@ module Cohere
115
116
  module_function
116
117
 
117
118
  SFM_READ = 0x10
119
+ SFC_GET_CHANNEL_MAP_INFO = 0x1100
118
120
  SRC_SINC_FASTEST = 2
119
121
  BACKENDS = %w[auto ffmpeg torchcodec librosa libsndfile].freeze
122
+ SQRT_HALF = Math.sqrt(0.5)
123
+ DEFAULT_MONO_MIXES = {
124
+ 1 => [1.0],
125
+ 2 => [SQRT_HALF, SQRT_HALF],
126
+ 3 => [SQRT_HALF, SQRT_HALF, 0.0], # FFmpeg's unspecified 3-channel default is 2.1, not 3.0
127
+ 4 => [SQRT_HALF, SQRT_HALF, 1.0, 0.5],
128
+ 5 => [SQRT_HALF, SQRT_HALF, 1.0, 0.5, 0.5],
129
+ 6 => [SQRT_HALF, SQRT_HALF, 1.0, 0.0, 0.5, 0.5],
130
+ 7 => [SQRT_HALF, SQRT_HALF, 1.0, 0.0, 0.5, 0.5, 0.5],
131
+ 8 => [SQRT_HALF, SQRT_HALF, 1.0, 0.0, 0.5, 0.5, 0.5, 0.5],
132
+ 10 => [SQRT_HALF, SQRT_HALF, 1.0, 0.0, 0.5, 0.5, SQRT_HALF, SQRT_HALF, 0.0, 0.0],
133
+ 12 => [
134
+ SQRT_HALF, SQRT_HALF, 1.0, 0.0, 0.5, 0.5, 0.5, 0.5,
135
+ SQRT_HALF, SQRT_HALF, 0.0, 0.0
136
+ ],
137
+ 16 => [
138
+ SQRT_HALF, SQRT_HALF, 1.0, 0.5, 0.5, 0.5, 0.5, 0.5,
139
+ SQRT_HALF, 0.0, SQRT_HALF, 0.0, 0.0, 0.0, 0.0, 0.0
140
+ ],
141
+ 24 => [
142
+ SQRT_HALF, SQRT_HALF, 1.0, 0.0, 0.5, 0.5, SQRT_HALF, SQRT_HALF,
143
+ 0.5, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0,
144
+ 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0
145
+ ]
146
+ }.transform_values(&:freeze).freeze
147
+ FFMPEG_7_DEFAULT_MONO_MIXES = {
148
+ 14 => [
149
+ SQRT_HALF, SQRT_HALF, 1.0, 0.0, 0.5, 0.5, SQRT_HALF, SQRT_HALF,
150
+ 0.5, 0.5, SQRT_HALF, SQRT_HALF, 0.0, 0.0
151
+ ]
152
+ }.transform_values(&:freeze).freeze
153
+ FFMPEG_8_DEFAULT_MONO_MIXES = {
154
+ 16 => [
155
+ SQRT_HALF, SQRT_HALF, 1.0, 0.0, 0.5, 0.5, SQRT_HALF, SQRT_HALF,
156
+ 0.5, 0.5, SQRT_HALF, SQRT_HALF, 0.0, 0.0, 0.0, 0.0
157
+ ]
158
+ }.transform_values(&:freeze).freeze
159
+ CHANNEL_POSITION_MONO_MIXES = {
160
+ 1 => 1.0, # mono
161
+ 2 => SQRT_HALF, # left
162
+ 3 => SQRT_HALF, # right
163
+ 4 => 1.0, # center
164
+ 5 => SQRT_HALF, # front left
165
+ 6 => SQRT_HALF, # front right
166
+ 7 => 1.0, # front center
167
+ 8 => 0.5, # rear center
168
+ 9 => 0.5, # rear left
169
+ 10 => 0.5, # rear right
170
+ 11 => 0.0, # low-frequency effects
171
+ 12 => SQRT_HALF, # front left of center
172
+ 13 => SQRT_HALF, # front right of center
173
+ 14 => 0.5, # side left
174
+ 15 => 0.5, # side right
175
+ 16 => 0.0, # top center
176
+ 17 => SQRT_HALF, # top front left
177
+ 18 => SQRT_HALF, # top front right
178
+ 19 => 0.0, # top front center
179
+ 20 => 0.0, # top rear left
180
+ 21 => 0.0, # top rear right
181
+ 22 => 0.0 # top rear center
182
+ }.freeze
120
183
 
121
184
  # Best-effort metadata probe used for public skipped-result parity. It
122
185
  # may inspect container headers and demuxer probe packets but never
@@ -131,8 +194,7 @@ module Cohere
131
194
  end
132
195
  return nil unless SoundFileABI::AVAILABLE
133
196
 
134
- SoundFileABI::SFInfo.malloc(Fiddle::RUBY_FREE) do |info|
135
- handle = SoundFileABI.sf_open(source.to_s, SFM_READ, info.to_ptr)
197
+ with_sound_file(source) do |handle, info|
136
198
  return nil if handle.null?
137
199
 
138
200
  frames = Integer(info.frames)
@@ -146,8 +208,6 @@ module Cohere
146
208
  end
147
209
  rescue Fiddle::DLError, SystemCallError, TranscriptionRuntimeError
148
210
  nil
149
- ensure
150
- SoundFileABI.sf_close(handle) if defined?(handle) && handle && !handle.null?
151
211
  end
152
212
 
153
213
  # Best-effort upper bound for the buffers governed by max_decoded_bytes.
@@ -161,10 +221,7 @@ module Cohere
161
221
  source = Pathname(path).expand_path
162
222
  return unless source.file?
163
223
 
164
- native_ffmpeg_available = %w[auto librosa].include?(requested) && FFmpegNative.available?
165
- use_ffmpeg = requested == "ffmpeg" || requested == "torchcodec" ||
166
- (%w[auto librosa].include?(requested) && native_ffmpeg_available)
167
- if use_ffmpeg
224
+ if ffmpeg_backend?(requested)
168
225
  duration = FFmpegNative.duration(source)
169
226
  return unless duration&.finite? && duration >= 0.0
170
227
 
@@ -172,8 +229,7 @@ module Cohere
172
229
  end
173
230
  return unless SoundFileABI::AVAILABLE
174
231
 
175
- SoundFileABI::SFInfo.malloc(Fiddle::RUBY_FREE) do |info|
176
- handle = SoundFileABI.sf_open(source.to_s, SFM_READ, info.to_ptr)
232
+ with_sound_file(source) do |handle, info|
177
233
  next if handle.null?
178
234
 
179
235
  frames = Integer(info.frames)
@@ -181,14 +237,10 @@ module Cohere
181
237
  source_rate = Integer(info.samplerate)
182
238
  next unless frames >= 0 && channels.positive? && source_rate.positive?
183
239
 
184
- input_bytes = frames * channels * Fiddle::SIZEOF_FLOAT
185
- output_frames = (frames * sample_rate.fdiv(source_rate)).ceil + 64
186
- [input_bytes, output_frames * Fiddle::SIZEOF_FLOAT].max
240
+ projected_decoded_bytes(frames, channels, source_rate, sample_rate)
187
241
  end
188
242
  rescue Fiddle::DLError, SystemCallError, TranscriptionRuntimeError
189
243
  nil
190
- ensure
191
- SoundFileABI.sf_close(handle) if defined?(handle) && handle && !handle.null?
192
244
  end
193
245
 
194
246
  def decode(path, backend: "auto", sample_rate: SAMPLE_RATE, max_decoded_bytes: 4 * (1024**3))
@@ -205,10 +257,7 @@ module Cohere
205
257
  raise TranscriptionInputError, "Input does not exist: #{source}" unless source.exist?
206
258
  raise TranscriptionInputError, "Input is not a regular file: #{source}" unless source.file?
207
259
 
208
- native_ffmpeg_available = %w[auto librosa].include?(requested) && FFmpegNative.available?
209
- use_ffmpeg = requested == "ffmpeg" || requested == "torchcodec" ||
210
- (%w[auto librosa].include?(requested) && native_ffmpeg_available)
211
- if use_ffmpeg
260
+ if ffmpeg_backend?(requested)
212
261
  samples = FFmpegNative.decode(
213
262
  source,
214
263
  sample_rate: sample_rate,
@@ -239,67 +288,54 @@ module Cohere
239
288
  raise TranscriptionRuntimeError, "libsndfile is required for native audio decoding: #{sound_file_error}"
240
289
  end
241
290
 
242
- SoundFileABI::SFInfo.malloc(Fiddle::RUBY_FREE) do |info|
243
- handle = SoundFileABI.sf_open(source.to_s, SFM_READ, info.to_ptr)
291
+ with_sound_file(source) do |handle, info|
244
292
  raise TranscriptionRuntimeError, "Cannot decode #{source}: #{SoundFileABI.sf_strerror(handle)}" if handle.null?
245
293
 
246
- begin
247
- frames = Integer(info.frames)
248
- channels = Integer(info.channels)
249
- source_rate = Integer(info.samplerate)
250
- unless frames >= 0 && channels.positive? && source_rate.positive?
251
- raise TranscriptionRuntimeError, "Decoder returned invalid audio metadata for #{source}"
252
- end
294
+ frames = Integer(info.frames)
295
+ channels = Integer(info.channels)
296
+ source_rate = Integer(info.samplerate)
297
+ unless frames >= 0 && channels.positive? && source_rate.positive?
298
+ raise TranscriptionRuntimeError, "Decoder returned invalid audio metadata for #{source}"
299
+ end
253
300
 
254
- input_bytes = frames * channels * Fiddle::SIZEOF_FLOAT
255
- output_frames = (frames * sample_rate.fdiv(source_rate)).ceil + 64
256
- projected_bytes = [input_bytes, output_frames * Fiddle::SIZEOF_FLOAT].max
257
- if max_decoded_bytes && projected_bytes > max_decoded_bytes
258
- raise TranscriptionRuntimeError,
259
- "Decoded audio exceeds the configured memory limit for #{source} " \
260
- "(#{projected_bytes} > #{max_decoded_bytes} bytes)"
261
- end
301
+ input_bytes = frames * channels * Fiddle::SIZEOF_FLOAT
302
+ projected_bytes = projected_decoded_bytes(frames, channels, source_rate, sample_rate)
303
+ if max_decoded_bytes && projected_bytes > max_decoded_bytes
304
+ raise DecodedAudioLimitError,
305
+ "Decoded audio exceeds the configured memory limit for #{source} " \
306
+ "(#{projected_bytes} > #{max_decoded_bytes} bytes)"
307
+ end
262
308
 
263
- raw = Fiddle::Pointer.malloc([input_bytes, 1].max, Fiddle::RUBY_FREE)
264
- read_frames = SoundFileABI.sf_readf_float(handle, raw, frames)
265
- raise TranscriptionRuntimeError, "Cannot decode #{source}: #{SoundFileABI.sf_strerror(handle)}" if read_frames.negative?
266
- raise TranscriptionRuntimeError, "Decoder returned more frames than allocated for #{source}" if read_frames > frames
309
+ raw = Fiddle::Pointer.malloc([input_bytes, 1].max, Fiddle::RUBY_FREE)
310
+ channel_map = sound_file_channel_map(handle, channels)
311
+ read_frames = SoundFileABI.sf_readf_float(handle, raw, frames)
312
+ raise TranscriptionRuntimeError, "Cannot decode #{source}: #{SoundFileABI.sf_strerror(handle)}" if read_frames.negative?
313
+ raise TranscriptionRuntimeError, "Decoder returned more frames than allocated for #{source}" if read_frames > frames
267
314
 
268
- frames = read_frames
269
- begin
270
- require "numo/narray"
271
- rescue LoadError => e
272
- raise TranscriptionRuntimeError, "numo-narray is required for decoded audio: #{e.message}"
273
- end
274
- interleaved = if frames.zero?
275
- nil
276
- else
277
- Numo::SFloat.from_binary(raw[0, frames * channels * Fiddle::SIZEOF_FLOAT])
278
- end
279
- mono = if frames.zero?
280
- Numo::SFloat.zeros(0)
281
- elsif channels == 1
282
- interleaved
283
- elsif channels == 2
284
- (interleaved.reshape(frames, channels).sum(1) * Math.sqrt(0.5)).cast_to(Numo::SFloat)
285
- else
286
- interleaved.reshape(frames, channels).mean(1).cast_to(Numo::SFloat)
287
- end
288
- samples = source_rate == sample_rate ? mono : resample(mono, source_rate, sample_rate, max_decoded_bytes)
289
- validate_finite!(samples)
290
- Decoded.new(
291
- samples: samples.freeze,
292
- sample_rate: sample_rate,
293
- backend: "libsndfile",
294
- fallback_reason: if %w[auto libsndfile].include?(requested)
295
- nil
296
- else
297
- "Ruby #{requested} compatibility mode uses the native libsndfile ABI"
298
- end
299
- )
300
- ensure
301
- SoundFileABI.sf_close(handle)
315
+ frames = read_frames
316
+ begin
317
+ require "numo/narray"
318
+ rescue LoadError => e
319
+ raise TranscriptionRuntimeError, "numo-narray is required for decoded audio: #{e.message}"
302
320
  end
321
+ interleaved = if frames.zero?
322
+ nil
323
+ else
324
+ Numo::SFloat.from_binary(raw[0, frames * channels * Fiddle::SIZEOF_FLOAT])
325
+ end
326
+ mono = downmix(interleaved, frames, channels, channel_map)
327
+ samples = source_rate == sample_rate ? mono : resample(mono, source_rate, sample_rate, max_decoded_bytes)
328
+ validate_finite!(samples)
329
+ Decoded.new(
330
+ samples: samples.freeze,
331
+ sample_rate: sample_rate,
332
+ backend: "libsndfile",
333
+ fallback_reason: if %w[auto libsndfile].include?(requested)
334
+ nil
335
+ else
336
+ "Ruby #{requested} compatibility mode uses the native libsndfile ABI"
337
+ end
338
+ )
303
339
  end
304
340
  end
305
341
 
@@ -318,7 +354,7 @@ module Cohere
318
354
  output_capacity = (samples.length * ratio).ceil + 64
319
355
  bytes = output_capacity * Fiddle::SIZEOF_FLOAT
320
356
  if max_decoded_bytes && bytes > max_decoded_bytes
321
- raise TranscriptionRuntimeError, "Resampled audio exceeds the configured memory limit"
357
+ raise DecodedAudioLimitError, "Resampled audio exceeds the configured memory limit"
322
358
  end
323
359
 
324
360
  input_string = samples.to_binary
@@ -357,6 +393,79 @@ module Cohere
357
393
  samples
358
394
  end
359
395
  private_class_method :validate_finite!
396
+
397
+ def ffmpeg_backend?(requested)
398
+ return true if %w[ffmpeg torchcodec].include?(requested)
399
+
400
+ %w[auto librosa].include?(requested) && FFmpegNative.available?
401
+ end
402
+ private_class_method :ffmpeg_backend?
403
+
404
+ def projected_decoded_bytes(frames, channels, source_rate, target_rate)
405
+ input_bytes = frames * channels * Fiddle::SIZEOF_FLOAT
406
+ output_frames = (frames * target_rate.fdiv(source_rate)).ceil + 64
407
+ [input_bytes, output_frames * Fiddle::SIZEOF_FLOAT].max
408
+ end
409
+ private_class_method :projected_decoded_bytes
410
+
411
+ def with_sound_file(source)
412
+ SoundFileABI::SFInfo.malloc(Fiddle::RUBY_FREE) do |info|
413
+ Thread.handle_interrupt(Object => :never) do
414
+ handle = SoundFileABI.sf_open(source.to_s, SFM_READ, info.to_ptr)
415
+ begin
416
+ Thread.handle_interrupt(Object => :immediate) { yield handle, info }
417
+ ensure
418
+ SoundFileABI.sf_close(handle) unless handle.null?
419
+ end
420
+ end
421
+ end
422
+ end
423
+ private_class_method :with_sound_file
424
+
425
+ # libswresample's default mono matrix. Explicit libsndfile positions
426
+ # preserve height-channel layouts, while the numeric fallbacks mirror
427
+ # the first standard layout selected by av_channel_layout_default.
428
+ def downmix(interleaved, frames, channels, channel_map)
429
+ return Numo::SFloat.zeros(0) if frames.zero?
430
+ return interleaved if channels == 1
431
+
432
+ matrix = interleaved.reshape(frames, channels)
433
+ weights = channel_map&.map { |position| CHANNEL_POSITION_MONO_MIXES[position] }
434
+ weights = nil if weights&.any?(&:nil?)
435
+ weights ||= default_mono_mix(channels)
436
+ return matrix.mean(1).cast_to(Numo::SFloat) unless weights
437
+
438
+ matrix.dot(Numo::SFloat[*weights]).cast_to(Numo::SFloat)
439
+ end
440
+ private_class_method :downmix
441
+
442
+ def default_mono_mix(channels)
443
+ case channels
444
+ when 14, 16
445
+ avutil_major = FFmpegNative.avutil_major
446
+ if avutil_major && avutil_major >= 60 && (weights = FFMPEG_8_DEFAULT_MONO_MIXES[channels])
447
+ return weights
448
+ end
449
+ if avutil_major && avutil_major >= 59 && (weights = FFMPEG_7_DEFAULT_MONO_MIXES[channels])
450
+ return weights
451
+ end
452
+ end
453
+
454
+ DEFAULT_MONO_MIXES[channels]
455
+ end
456
+ private_class_method :default_mono_mix
457
+
458
+ def sound_file_channel_map(handle, channels)
459
+ buffer = Array.new(channels, 0).pack("i!*")
460
+ available = SoundFileABI.sf_command(
461
+ handle,
462
+ SFC_GET_CHANNEL_MAP_INFO,
463
+ Fiddle::Pointer[buffer],
464
+ buffer.bytesize
465
+ )
466
+ available == 1 ? buffer.unpack("i!*") : nil
467
+ end
468
+ private_class_method :sound_file_channel_map
360
469
  end
361
470
  end
362
471
  end
@@ -4,16 +4,21 @@ require "fiddle"
4
4
  require "rbconfig"
5
5
 
6
6
  require_relative "../errors"
7
+ require_relative "../internal/interruptible_native_call"
7
8
 
8
9
  module Cohere
9
10
  module Transcribe
10
11
  module Audio
12
+ class DecodedAudioLimitError < TranscriptionRuntimeError; end
13
+
11
14
  # Lazy binding for the gem's subprocess-free libav decoder adapter. The
12
15
  # adapter itself dynamically selects a compatible FFmpeg 4-8 runtime;
13
16
  # Ruby never launches ffmpeg and does not retain a Python codec runtime.
14
17
  module FFmpegNative
15
18
  ERROR_CAPACITY = 1_024
16
19
  CANCELLED_STATUS = 6
20
+ DECODED_AUDIO_LIMIT_STATUS = 4
21
+ CANCELLATION_JOIN_INTERVAL = 0.01
17
22
 
18
23
  class Library
19
24
  FUNCTIONS = {
@@ -24,6 +29,10 @@ module Cohere
24
29
  free: [[:voidp], :void]
25
30
  }.freeze
26
31
 
32
+ OPTIONAL_FUNCTIONS = {
33
+ versions: [%i[voidp size_t], :int]
34
+ }.freeze
35
+
27
36
  TYPE_MAP = {
28
37
  void: Fiddle::TYPE_VOID,
29
38
  voidp: Fiddle::TYPE_VOIDP,
@@ -35,6 +44,9 @@ module Cohere
35
44
  class << self
36
45
  def load
37
46
  @mutex ||= Mutex.new
47
+ # A failed load is deliberately retryable. Embedders may set an
48
+ # adapter override or make a packaged/system library available
49
+ # after an earlier best-effort audio probe.
38
50
  @mutex.synchronize { @instance ||= load_uncached }
39
51
  end
40
52
 
@@ -84,20 +96,20 @@ module Cohere
84
96
  end
85
97
  end
86
98
 
87
- attr_reader :path, :diagnostic
99
+ attr_reader :path, :diagnostic, :ffmpeg_versions, :avutil_major
88
100
 
89
101
  def initialize(path)
90
102
  @path = path
91
103
  @handle = Fiddle::Handle.new(path, Fiddle::RTLD_NOW)
92
- @functions = FUNCTIONS.to_h do |name, (arguments, result)|
93
- address = @handle["cohere_audio_ffmpeg_#{name}"]
94
- function = Fiddle::Function.new(
95
- address,
96
- arguments.map { |type| TYPE_MAP.fetch(type) },
97
- TYPE_MAP.fetch(result)
98
- )
99
- [name, function]
100
- end.freeze
104
+ functions = FUNCTIONS.to_h do |name, signature|
105
+ [name, bind_function(name, signature)]
106
+ end
107
+ OPTIONAL_FUNCTIONS.each do |name, signature|
108
+ functions[name] = bind_function(name, signature)
109
+ rescue Fiddle::DLError
110
+ nil
111
+ end
112
+ @functions = functions.freeze
101
113
  end
102
114
 
103
115
  def probe!
@@ -105,31 +117,41 @@ module Cohere
105
117
  message[0, ERROR_CAPACITY] = "\0" * ERROR_CAPACITY
106
118
  status = @functions.fetch(:probe).call(message, ERROR_CAPACITY)
107
119
  @diagnostic = message.to_s.force_encoding(Encoding::UTF_8).scrub.freeze
108
- return self if status.zero?
120
+ raise TranscriptionRuntimeError, "Native FFmpeg libraries are unavailable: #{@diagnostic}" unless status.zero?
109
121
 
110
- raise TranscriptionRuntimeError, "Native FFmpeg libraries are unavailable: #{@diagnostic}"
122
+ versions = @functions[:versions]
123
+ return clear_ffmpeg_versions unless versions
124
+
125
+ tuple_bytes = 4 * Fiddle::SIZEOF_INT
126
+ tuple = Fiddle::Pointer.malloc(tuple_bytes, Fiddle::RUBY_FREE)
127
+ tuple[0, tuple_bytes] = [0, 0, 0, 0].pack("i!*")
128
+ version_status = versions.call(tuple, 4)
129
+ unless version_status.zero?
130
+ raise TranscriptionRuntimeError,
131
+ "Native FFmpeg libraries did not report their version tuple: #{@diagnostic}"
132
+ end
133
+
134
+ @ffmpeg_versions = tuple[0, tuple_bytes].unpack("i!4").freeze
135
+ unless @ffmpeg_versions.all?(&:positive?)
136
+ raise TranscriptionRuntimeError,
137
+ "Native FFmpeg libraries reported an invalid version tuple: #{@ffmpeg_versions.inspect}"
138
+ end
139
+
140
+ @avutil_major = @ffmpeg_versions.fetch(2)
141
+ self
111
142
  end
112
143
 
113
144
  def decode(path, sample_rate:, max_decoded_bytes:)
114
- worker = Thread.new do
145
+ Internal::InterruptibleNativeCall.run(
146
+ cancel: -> { @functions.fetch(:cancel).call },
147
+ join_interval: CANCELLATION_JOIN_INTERVAL,
148
+ missing_outcome: TranscriptionRuntimeError.new(
149
+ "Native FFmpeg decode worker exited without reporting an outcome"
150
+ ),
151
+ thread_name: "cohere-ffmpeg-decode"
152
+ ) do
115
153
  decode_owned(path, sample_rate: sample_rate, max_decoded_bytes: max_decoded_bytes)
116
154
  end
117
- worker.name = "cohere-ffmpeg-decode" if worker.respond_to?(:name=)
118
- worker.report_on_exception = false
119
- completed = false
120
- result = worker.value
121
- completed = true
122
- result
123
- ensure
124
- if defined?(worker) && worker && !completed
125
- Thread.handle_interrupt(Exception => :never) do
126
- @functions.fetch(:cancel).call if worker.alive?
127
- rescue StandardError
128
- nil
129
- ensure
130
- worker.join
131
- end
132
- end
133
155
  end
134
156
 
135
157
  def decode_owned(path, sample_rate:, max_decoded_bytes:)
@@ -142,7 +164,7 @@ module Cohere
142
164
  maximum = max_decoded_bytes || 0
143
165
 
144
166
  address = 0
145
- Thread.handle_interrupt(Exception => :never) do
167
+ Thread.handle_interrupt(Object => :never) do
146
168
  status = @functions.fetch(:decode).call(
147
169
  c_string(path.to_s),
148
170
  sample_rate,
@@ -154,11 +176,14 @@ module Cohere
154
176
  )
155
177
  address = output_slot[0, Fiddle::SIZEOF_VOIDP].unpack1("J")
156
178
  begin
157
- Thread.handle_interrupt(Exception => :immediate) do
179
+ Thread.handle_interrupt(Object => :immediate) do
158
180
  count = count_slot[0, Fiddle::SIZEOF_INT64_T].unpack1("q")
159
181
  detail = message.to_s.force_encoding(Encoding::UTF_8).scrub
160
182
  unless status.zero?
161
183
  raise Interrupt, "Native FFmpeg decode was cancelled for #{path}: #{detail}" if status == CANCELLED_STATUS
184
+ if status == DECODED_AUDIO_LIMIT_STATUS
185
+ raise DecodedAudioLimitError, "Cannot decode #{path} through native FFmpeg: #{detail}"
186
+ end
162
187
 
163
188
  raise TranscriptionRuntimeError, "Cannot decode #{path} through native FFmpeg: #{detail}"
164
189
  end
@@ -168,7 +193,7 @@ module Cohere
168
193
 
169
194
  bytes = count * Fiddle::SIZEOF_FLOAT
170
195
  if max_decoded_bytes && bytes > max_decoded_bytes
171
- raise TranscriptionRuntimeError,
196
+ raise DecodedAudioLimitError,
172
197
  "Native FFmpeg exceeded the configured decoded-audio memory limit for #{path}"
173
198
  end
174
199
 
@@ -223,6 +248,21 @@ module Cohere
223
248
 
224
249
  private :decode_owned
225
250
 
251
+ def bind_function(name, (arguments, result))
252
+ address = @handle["cohere_audio_ffmpeg_#{name}"]
253
+ Fiddle::Function.new(
254
+ address,
255
+ arguments.map { |type| TYPE_MAP.fetch(type) },
256
+ TYPE_MAP.fetch(result)
257
+ )
258
+ end
259
+
260
+ def clear_ffmpeg_versions
261
+ @ffmpeg_versions = nil
262
+ @avutil_major = nil
263
+ self
264
+ end
265
+
226
266
  def c_string(value)
227
267
  Fiddle::Pointer["#{value}\0"]
228
268
  end
@@ -247,6 +287,18 @@ module Cohere
247
287
  e.message
248
288
  end
249
289
 
290
+ def avutil_major
291
+ library.avutil_major
292
+ rescue TranscriptionRuntimeError
293
+ nil
294
+ end
295
+
296
+ def ffmpeg_versions
297
+ library.ffmpeg_versions
298
+ rescue TranscriptionRuntimeError
299
+ nil
300
+ end
301
+
250
302
  def decode(path, sample_rate:, max_decoded_bytes:)
251
303
  library.decode(path, sample_rate: sample_rate, max_decoded_bytes: max_decoded_bytes)
252
304
  end
@@ -33,6 +33,7 @@ module Cohere
33
33
  start_time = Float(raw_start)
34
34
  end_time = Float(raw_end)
35
35
  raise ArgumentError, "Segment #{index} has non-finite bounds" unless start_time.finite? && end_time.finite?
36
+ raise ArgumentError, "Segment #{index} has inverted bounds" if end_time < start_time
36
37
  if start_time < -tolerance || end_time > duration + tolerance
37
38
  raise ArgumentError,
38
39
  format("Segment %<index>d lies outside the audio: %<start>.6f..%<end>.6f for %<duration>.6fs",