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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f494c8ad68293a60a51ea1510bd3ce55b81867f9014d4a5dcbd5c8499e970bde
4
- data.tar.gz: efe16c2e6c4b0c2e4924716fc4023dffe0480375a075b8138c974b41dddb9115
3
+ metadata.gz: edcc763ef9c3c66e41fc508fb3c45fca037623e3fc3be03529c553b78f488a29
4
+ data.tar.gz: 675bf23b2dd0c1966a434e9e078ca45bcdc86154ae04c03fce72312801eb8f52
5
5
  SHA512:
6
- metadata.gz: d43d30ab5c72a800e94148d368475aaf97a8f3a8ca97bd8ea24804783dcfea3450cef86accfdacd487064f6c7636c3811ca054d6f51c75a040dcc050a6634027
7
- data.tar.gz: 7e506a692ae9025e4d825f5330a94685a2500f296745dbdf671bdf632799873278d88f21f93e1d36b9a382b8c8e1eae834a82267f7851da2e41b2371b6312f25
6
+ metadata.gz: 627b836b339dde2265e72f8adc73605052eada1a030b24f97b48720b1088b8139905b857a7a796da7e6b608903cc42c05bb50daa3db4b42537057820fc810e8d
7
+ data.tar.gz: 3f310c45da7aecfa19952b329b6b52bf878e4772491d234029b4a822b2ed79c3efefb6c403b6474f42038c9516e84312c46b59286fa1598bde8b0a4363515654
data/CHANGELOG.md CHANGED
@@ -1,5 +1,14 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.1.3] - 2026-07-18
4
+
5
+ - Close every libsndfile handle, directory descriptor, decoded buffer, and native session exactly once, defer thread termination through ownership handoffs and durable commit boundaries, cancel and join dedicated native workers before their callers terminate, reject empty native inference rows locally, and consolidate detached session ownership without retaining wrapper objects.
6
+ - Match native FFmpeg mono mixing for standard multichannel and height-channel layouts across supported FFmpeg generations, obtain the FFmpeg version tuple through an optional native ABI symbol while retaining compatible older audio adapters, classify decoded-audio ceiling failures explicitly, prepare unknown sizes concurrently, retain successful grouped decodes across useful ceiling retries, release retained PCM before a full-ceiling retry, and keep lazy metadata probes on the caller's native stack.
7
+ - Restore warm-cache Hub operation for temporary connection, rate-limit, server, malformed-JSON, and missing-or-invalid-commit responses while retaining online branch revalidation, bounded short-lived memoization, independent concurrent refreshes, bounded standard bare-SHA refs published atomically as best-effort hints, role-specific shared-cache modes for directories, immutable downloads, replaceable refs, and download locks, readable-lock fallback with clear remediation for inaccessible older locks, tolerant mode updates, repository-file listings that reject an empty or wholly malformed result, consistent public transient errors, and definitive authentication or missing-repository errors.
8
+ - Reject invalid byte paths before transcription or publication, share byte-to-UTF-8 normalization across public surfaces, treat publication-parent changes during read-only verification as reprocessing decisions, reject inverted segment bounds, and coordinate output publication through output-adjacent locks that remain held across rescue contexts, propagate release failures, support shared directories and verified read-only skips, reject unowned multi-user-writable registries without a sticky bit, revalidate the authoritative lock at checkpoint and output commit boundaries, unify state and output recovery across exceptions, nonlocal exits, and thread termination, report incomplete recovery for ordinary failures and nonlocal exits with retained-backup paths and preserved causes, let pending thread termination remain terminal at every recovery stage, tolerate disappearance of the released 0.1.2 compatibility lock only while the canonical lock remains held, reject replacement even inside rescue contexts, and remove that compatibility path at the final 0.2.0 release while retaining it through prereleases.
9
+ - Combine full-storage tensor checksum validation with conversion, simplify ZIP64 locator and checksum bookkeeping, preserve the discovered CUDA toolkit runtime path, and exclude untracked native build artifacts from locally built gems.
10
+ - Match the pinned Python word-aligner geometry for sub-30-second audio, require exact command-line enum values, and use the public `OUTPUT_FORMATS` constant as the single accepted-format source for types and the CLI.
11
+
3
12
  ## [0.1.2] - 2026-07-16
4
13
 
5
14
  - Correct macOS creation-mode delivery for descriptor-relative staged files and turn concurrent output replacement during preflight into a normal reprocessing decision.
data/README.md CHANGED
@@ -18,7 +18,7 @@ The native extension builds a portable CPU backend by default. Accelerator avail
18
18
 
19
19
  Dense conversion and inference support F16, BF16, and F32 GGUF weights for the pinned checkpoint and compatible Dense fine-tunes. CPU execution resolves to FP32. CUDA `auto` selects BF16 when the loaded runtime reports hardware support and otherwise selects FP16; MPS `auto` selects FP16. An explicit unsupported BF16 accelerator request fails instead of silently changing precision.
20
20
 
21
- Audio decoding never launches the `ffmpeg` executable. The packaged `libcohere_audio` adapter dynamically binds a compatible libavformat/libavcodec/libavutil/libswresample tuple and decodes the first audio stream directly to mono 16 kHz float PCM. This path covers AAC, AIFF, ALAC, FLAC, M4A, MP3, MP4, Ogg/Vorbis, Opus, WAV, WebM, and WMA. Explicit `torchcodec` and `librosa` compatibility modes use the same FFmpeg codec runtime through that C ABI, preserving the complete accepted-format set while reporting the concrete decoder in result provenance. If the adapter is absent, `auto` and `librosa` can fall back to libsndfile/libsamplerate for the formats those libraries support.
21
+ Audio decoding never launches the `ffmpeg` executable. The packaged `libcohere_audio` adapter dynamically binds a compatible libavformat/libavcodec/libavutil/libswresample tuple and decodes the first audio stream directly to mono 16 kHz float PCM. This path covers AAC, AIFF, ALAC, FLAC, M4A, MP3, MP4, Ogg/Vorbis, Opus, WAV, WebM, and WMA. Explicit `torchcodec` and `librosa` compatibility modes use the same FFmpeg codec runtime through that C ABI, preserving the complete accepted-format set while reporting the concrete decoder in result provenance. If the adapter is absent, `auto` and `librosa` can fall back to libsndfile/libsamplerate for the formats those libraries support. Audio adapters built before version metadata was added remain loadable; without that metadata the Ruby wrapper uses the older channel-layout table, which is identical for common inputs with eight or fewer channels but can differ for unusual 14- or 16-channel layouts.
22
22
 
23
23
  The FFmpeg adapter gives duration probes and full decodes monotonic deadlines (30 seconds and one hour, respectively) and shares a generation-based cooperative cancellation hook. It supplies FFmpeg's public `AVIOInterruptCB` to input I/O and also checks cancellation between codec and resampler calls. Cancellation therefore takes effect when FFmpeg invokes that callback or returns control; it does not forcibly unwind an uninterruptible codec or system call.
24
24
 
@@ -126,7 +126,7 @@ ensure
126
126
  end
127
127
  ```
128
128
 
129
- Inputs may be a string, a path-like object, or an ordered array of files and directories. Directory expansion is deterministic, recursive by default, and deduplicates canonical paths. A run preserves that expanded order and provides `successful`, `failed`, `skipped`, `single`, and `ok?` helpers.
129
+ Inputs may be a string, a path-like object, or an ordered array of files and directories. Public path and model-reference strings must contain valid UTF-8 bytes; binary-encoded strings containing valid UTF-8 are normalized without changing their bytes, while undecodable byte names are rejected before work begins. Directory expansion is deterministic, recursive by default, and deduplicates canonical paths. A run preserves that expanded order and provides `successful`, `failed`, `skipped`, `single`, and `ok?` helpers.
130
130
 
131
131
  Per-file media failures are returned as failed results so the rest of a batch can finish. Pass `raise_on_error: true` to raise `BatchTranscriptionError`; its `run` still contains every completed result.
132
132
 
@@ -162,7 +162,9 @@ run = Cohere::Transcribe.transcribe("recordings", options: options)
162
162
 
163
163
  All formats for one source are staged before commit. Existing outputs are preserved if staging fails, and directory-relative structure is retained beneath `output_dir`.
164
164
 
165
- Durable publication binds the planned output root and parent directory inodes, then performs staging, backup, commit, rollback, and cleanup relative to a retained directory descriptor. This prevents a concurrent directory rename or symlink replacement from redirecting transcript, checkpoint, manifest, or profile bytes. Publication fails closed if the planned path identity changes or if the platform lacks `O_NOFOLLOW` and the POSIX `openat`, `renameat`, and `unlinkat` primitives; the supported Linux and macOS targets provide them.
165
+ Concurrent publishers coordinate through a persistent `.cohere-transcribe-locks` directory beside the outputs. Its access mode follows the output directory so collaborators who can publish there can use the same locks. New or current-user-owned shared writable registries receive the sticky bit where the filesystem supports it; an unowned multi-user-writable registry without that bit is rejected with remediation guidance. The authoritative output-adjacent lock is revalidated at checkpoint and output commit boundaries, so replacing its path aborts the commit even on filesystems that cannot retain the sticky bit. A fully verified `existing: "skip"` run does not need to create or modify the lock directory. The 0.1 release series also acquires the released 0.1.2 temporary lock for mixed-version coordination; disappearance of that temporary path is tolerated only while the canonical lock remains held, replacement is rejected even when verification runs inside a rescue handler, compatibility remains enabled through 0.2.0 prereleases, and the path is removed at the final 0.2.0 release. A filesystem that rejects an exclusive lock on an otherwise read-only lock file returns a typed runtime error; it never substitutes a non-exclusive lock.
166
+
167
+ Durable publication binds the planned output root and parent directory inodes, then performs staging, backup, commit, rollback, and cleanup relative to a retained directory descriptor. A concurrent directory rename or symlink replacement therefore cannot redirect transcript, checkpoint, manifest, or profile bytes. If an exception or nonlocal exit leaves recovery incomplete before commit completion, the typed failure names every retained backup or temporary and preserves the original exception as its cause. Thread termination still attempts rollback and cleanup but remains terminal even when that recovery is incomplete. Ordinary post-commit cleanup failures are also typed; once a commit is durable, post-commit cleanup remains best effort during termination. Publication returns a typed error if the planned path identity changes or if the platform lacks `O_NOFOLLOW` and the POSIX `openat`, `renameat`, and `unlinkat` primitives; the supported Linux and macOS targets provide them.
166
168
 
167
169
  Publication state records the source's canonical path, device, inode, size, nanosecond mtime, and nanosecond ctime, plus checksums for the state payload and published artifacts. With `existing: "skip"`, a verified manifest is resolved before PCM decode, VAD, or a Dense model session is opened; a best-effort metadata duration probe supplies the skipped result's duration without materializing PCM.
168
170
 
@@ -187,7 +189,7 @@ The segment runtime uses CrispASR's padded Cohere encoder and ragged greedy deco
187
189
 
188
190
  Native inference failures carry a thread-local error kind and diagnostic message across the C ABI. Ruby maps invalid arguments and invariant violations to fatal failures, allocator failures to OOM, and ordinary runtime failures to isolatable errors. Only typed OOM failures teach a smaller adaptive batch cap; fatal failures open the retained session's circuit breaker.
189
191
 
190
- For multi-file runs, `preprocess_workers` concurrently decodes and segments one ordered preparation group while `pipeline_preparation` permits exactly one next group to overlap current ASR. Each ordinary pipelined group is capped at the smaller of half `audio_memory_gb` and 512 MiB of retained mono float PCM, with metadata-based size estimates assigning per-file ceilings instead of dividing the cap equally. A file whose estimate exceeds the group cap, or whose size cannot be estimated, is prepared alone with the full `audio_memory_gb` ceiling and no adjacent-group overlap. Native codec transients can add short-lived overhead. Disable `pipeline_preparation` for a fully sequential path. Automatic worker selection uses one worker for one file and at most two otherwise; explicit counts are capped by available processors and the group size. Decode/VAD failures remain isolated per file, and results, progress events, and publication always follow input order. Word alignment does not retain those prepared waveforms across its phase barrier. It re-decodes one file at a time through the concrete backend recorded during ASR (with one look-ahead only when the adjacent PCM pair fits `audio_memory_gb`) and rejects backend or sample-count drift. Resumable ASR checkpoints enter the alignment phase directly without reopening Dense or repeating decode/VAD preparation.
192
+ For multi-file runs, `preprocess_workers` concurrently decodes and segments one ordered preparation group while `pipeline_preparation` permits exactly one next group to overlap current ASR. Each ordinary pipelined group is capped at the smaller of half `audio_memory_gb` and 512 MiB of retained mono float PCM, with metadata-based size estimates assigning per-file ceilings instead of dividing the cap equally. Files without usable estimates initially share the group cap equally. A file whose known estimate exceeds the group cap is prepared alone with the full `audio_memory_gb` ceiling and no adjacent-group overlap. If metadata is missing or underestimates one grouped decode, only that file is retried while successful decoded audio remains retained whenever the remaining configured PCM budget is sufficient. If the retry still needs the complete ceiling, retained audio after that file is released and only those successful entries are prepared again in order; unrelated failures are not repeated. Native codec transients can add short-lived overhead. Disable `pipeline_preparation` for a fully sequential path. Automatic worker selection uses one worker for one file and at most two otherwise; explicit counts are capped by available processors and the group size. Decode/VAD failures remain isolated per file, and results, progress events, and publication always follow input order. Word alignment does not retain those prepared waveforms across its phase barrier. It re-decodes one file at a time through the concrete backend recorded during ASR (with one look-ahead only when the adjacent PCM pair fits `audio_memory_gb`) and rejects backend or sample-count drift. Resumable ASR checkpoints enter the alignment phase directly without reopening Dense or repeating decode/VAD preparation.
191
193
 
192
194
  `pin_memory` remains accepted for Python API/CLI compatibility, but resolves to `false` in this ggml runtime. Ruby passes float PCM directly through the native session ABI, so there is no PyTorch host tensor to pin and no nonblocking tensor transfer for the option to accelerate.
193
195
 
@@ -202,7 +204,7 @@ The packed-Torch tuning options remain effective without Python. For requested `
202
204
  The packaged graph has temporal input `[seq_len, 576]` but recurrent h/c inputs fixed at `[1, 1, 128]`; it has no file-batch axis, lengths, or mask. Concatenating files would therefore leak recurrent state between recordings. Ruby instead uses one thread-confined session per active file and makes `vad_batch_size` an upper bound on that independent-file concurrency for requested `auto`/`torch`. The effective count is also bounded by `preprocess_workers`, CPU availability, group size, and the sequential preparation mode. Profiles consequently report `max_files_per_call: 1`, exact temporal model-call/frame counts, and provider options matching ONNX Runtime introspection (`CPUExecutionProvider: {}`), while the configured batch/block values and effective temporal block remain visible.
203
205
  - `vad: "auditok"` uses a native Ruby implementation of Auditok's 50 ms PCM16 log-RMS tokenizer.
204
206
  - `vad: "none"` creates bounded fixed windows using `max_dur`.
205
- - `alignment: "word"` computes full-file emissions with the exact pinned MMS-300M forced-aligner ONNX export, then runs a pure-Ruby float32 CTC Viterbi kernel. An unalignable segment alone falls back to bounded uniform timing without dropping transcript words.
207
+ - `alignment: "word"` computes full-file emissions with the exact pinned MMS-300M forced-aligner ONNX export, then runs a pure-Ruby float32 CTC Viterbi kernel. Audio shorter than 30 seconds follows the pinned aligner's direct waveform geometry; inputs shorter than its 400-sample receptive field receive only the minimum right padding. An unalignable segment alone falls back to bounded uniform timing without dropping transcript words.
206
208
  - `alignment: "segment"` distributes words uniformly over the segment's speech spans.
207
209
  - `alignment: "none"` returns plain text without words or cues.
208
210
 
@@ -215,7 +217,7 @@ CohereLabs/cohere-transcribe-arabic-07-2026
215
217
  0a8193caa4f3f92131471ab08824e488141cb392
216
218
  ```
217
219
 
218
- Hub artifacts reuse the standard Hugging Face cache. Symbolic branches and tags are revalidated while online; set `HF_HUB_OFFLINE=1` to resolve them only from an already complete cached snapshot. Dense Safetensors and `pytorch_model.bin` weights are streamed into a GGUF conversion once and reused from `~/.cache/cohere-transcribe`. PyTorch metadata is decoded by a restricted, allowlist-only Ruby reader; it never imports Python or executes pickle globals. Current `torch.save` ZIP files and the preceding raw-storage stream format are supported, including sharded indexes and strided tensors. Ancient tar-format checkpoints are rejected because they cannot be interpreted with the same restricted weights-only contract; re-save those as Safetensors or a current state dict. The retained-session identity includes the resolved device and dtype as well as the model identity. Each converted artifact has an independent cache key for its output dtype and a SHA-256 source fingerprint over the model ID/revision, relative source paths, sizes, mtimes, and ctimes, so even a same-size, mtime-preserving rewrite invalidates a local fine-tune through its changed ctime. A sidecar completion marker binds that source fingerprint, output dtype, and cache layout to the converted GGUF's device, inode, size, mtime, and ctime. This marker is stored beside the model as `*.complete.json`. The cache accepts only regular, non-symlink GGUF/marker files; conversion locks use no-follow opens where available and verify that the opened descriptor still matches the path's device and inode.
220
+ Hub artifacts reuse the standard Hugging Face cache. Symbolic branches and tags are revalidated while online with a bounded per-client memo of at most five seconds; concurrent misses refresh independently and each caller uses its own fetched commit. Standard bare-SHA ref reads are capped at 41 bytes, and ref files are atomically replaced as best-effort cache hints without invalidating a valid online result when the cache is unreadable or read-only. Shared-cache directories preserve setgid and execute-gated access; snapshot directories receive a sticky bit where supported, ref directories remain replaceable for atomic collaborator updates, immutable payloads and refs inherit collaborator read access without write access, and download locks inherit the read/write access needed for coordination. Mode updates that the filesystem cannot represent are tolerated, current-user-owned older cache entries are upgraded when possible, readable non-writable locks can still coordinate where the filesystem permits an exclusive lock, and an inaccessible older lock returns a typed error with remediation instead of being replaced while it may still be held. Temporary connection, rate-limit, server, malformed-JSON, or missing-or-invalid-commit responses can reuse a complete cached file snapshot, while authentication and missing-repository responses remain definitive errors. Malformed individual repository-file entries are ignored when valid siblings remain, but an empty or wholly malformed listing is reported directly. Set `HF_HUB_OFFLINE=1` to resolve symbolic revisions only from an already complete cached snapshot; an offline symbolic revision remains at its last successfully cached commit until a later online refresh succeeds. Dense Safetensors and `pytorch_model.bin` weights are streamed into a GGUF conversion once and reused from `~/.cache/cohere-transcribe`. PyTorch metadata is decoded by a restricted, allowlist-only Ruby reader; it never imports Python or executes pickle globals. Current `torch.save` ZIP files and the preceding raw-storage stream format are supported, including sharded indexes and strided tensors. Ancient tar-format checkpoints are rejected because they cannot be interpreted with the same restricted weights-only contract; re-save those as Safetensors or a current state dict. The retained-session identity includes the resolved device and dtype as well as the model identity. Each converted artifact has an independent cache key for its output dtype and a SHA-256 source fingerprint over the model ID/revision, relative source paths, sizes, mtimes, and ctimes, so even a same-size, mtime-preserving rewrite invalidates a local fine-tune through its changed ctime. A sidecar completion marker binds that source fingerprint, output dtype, and cache layout to the converted GGUF's device, inode, size, mtime, and ctime. This marker is stored beside the model as `*.complete.json`. The cache accepts only regular, non-symlink GGUF/marker files; conversion locks use no-follow opens where available and verify that the opened descriptor still matches the path's device and inode. Cross-process output locks use an output-adjacent `.cohere-transcribe-locks/` registry derived from the canonical output set and also acquire the released 0.1.2 temporary lock during this transition.
219
221
 
220
222
  Word mode uses `onnx-community/mms-300m-1130-forced-aligner-ONNX@2100fb247d8e43962eef24491597fbeb8b469531`, an ONNX export of `MahmoudAshraf/mms-300m-1130-forced-aligner@49402e9577b1158620820667c218cd494cc44486`. The default `align_dtype: "fp32"` works with the CPU provider even when Dense ASR runs on CUDA. `align_dtype: "fp16"` requires a CUDA-enabled ONNX Runtime; point `COHERE_TRANSCRIBE_ONNXRUNTIME_LIBRARY` at that runtime when it is not the one supplied by the installed `onnxruntime` gem. The runtime verifies the complete model file against a pinned byte size and SHA-256 before loading it. These downloaded model weights are CC-BY-NC-4.0 and are not distributed inside the gem; see `lib/cohere/transcribe/alignment/ATTRIBUTION.md` for provenance and notices.
221
223
 
@@ -254,7 +256,7 @@ Installed-gem WER measurements use the standalone runner documented in [`benchma
254
256
 
255
257
  ### Releasing
256
258
 
257
- Add a repository Actions secret named `RUBYGEMS_AUTH_TOKEN` containing a RubyGems API key with permission to push `cohere-transcribe`. Publishing a GitHub Release tagged `v0.1.2` runs the release workflow, verifies that the tag matches `Cohere::Transcribe::VERSION`, builds the exact `cohere-transcribe-0.1.2.gem` artifact, and pushes it to RubyGems.org.
259
+ Add a repository Actions secret named `RUBYGEMS_AUTH_TOKEN` containing a RubyGems API key with permission to push `cohere-transcribe`. Publishing a GitHub Release tagged `0.1.3` runs the release workflow, verifies that the tag matches `Cohere::Transcribe::VERSION`, builds the exact `cohere-transcribe-0.1.3.gem` artifact, and pushes it to RubyGems.org. A leading `v` is also accepted by the release check.
258
260
 
259
261
  Normal CI runs the Ruby suite, style checks, signature validation, native CPU build and ABI smoke checks, and source-gem build on Linux and macOS. It does not run the installed-gem WER or performance benchmarks.
260
262
 
@@ -0,0 +1,6 @@
1
+ /Makefile
2
+ /build/
3
+ /cuda-build/
4
+ /stage/
5
+ /cuda-stage/
6
+ /mkmf.log
@@ -200,6 +200,14 @@ endif()
200
200
  if(COHERE_TRANSCRIBE_CUDA)
201
201
  target_compile_definitions(crispasr PRIVATE GGML_USE_CUDA)
202
202
  target_link_libraries(crispasr PRIVATE ggml-cuda)
203
+ # Source gems are compiled on the destination machine. Preserve the exact
204
+ # toolkit library directory found by CMake so non-system CUDA installs
205
+ # (including tarball and environment-managed toolkits) remain loadable after
206
+ # RubyGems moves libcrispasr out of the build tree.
207
+ set_property(TARGET crispasr APPEND PROPERTY
208
+ BUILD_RPATH "${CUDAToolkit_LIBRARY_DIR}")
209
+ set_property(TARGET crispasr APPEND PROPERTY
210
+ INSTALL_RPATH "${CUDAToolkit_LIBRARY_DIR}")
203
211
  endif()
204
212
 
205
213
  if(MSVC)
@@ -1338,6 +1338,26 @@ COHERE_AUDIO_EXPORT int cohere_audio_ffmpeg_probe(char* diagnostic, std::size_t
1338
1338
  }
1339
1339
  }
1340
1340
 
1341
+ COHERE_AUDIO_EXPORT int cohere_audio_ffmpeg_versions(int* versions, std::size_t count) {
1342
+ if (versions && count > 0)
1343
+ std::fill_n(versions, std::min<std::size_t>(count, 4), 0);
1344
+ if (!versions || count < 4)
1345
+ return 2;
1346
+
1347
+ try {
1348
+ Runtime& api = runtime();
1349
+ if (!api.available)
1350
+ return 1;
1351
+ versions[0] = api.format_major;
1352
+ versions[1] = api.codec_major;
1353
+ versions[2] = api.util_major;
1354
+ versions[3] = api.resample_major;
1355
+ return 0;
1356
+ } catch (...) {
1357
+ return 1;
1358
+ }
1359
+ }
1360
+
1341
1361
  COHERE_AUDIO_EXPORT int cohere_audio_ffmpeg_decode(
1342
1362
  const char* path,
1343
1363
  int target_rate,
@@ -1,4 +1,5 @@
1
1
  _cohere_audio_ffmpeg_probe
2
+ _cohere_audio_ffmpeg_versions
2
3
  _cohere_audio_ffmpeg_decode
3
4
  _cohere_audio_ffmpeg_duration
4
5
  _cohere_audio_ffmpeg_cancel
@@ -1,6 +1,7 @@
1
1
  {
2
2
  global:
3
3
  cohere_audio_ffmpeg_probe;
4
+ cohere_audio_ffmpeg_versions;
4
5
  cohere_audio_ffmpeg_decode;
5
6
  cohere_audio_ffmpeg_duration;
6
7
  cohere_audio_ffmpeg_cancel;
@@ -154,6 +154,7 @@ abort "no packaged libcohere_audio found in #{directory}" unless audio_library
154
154
  audio_handle = Fiddle::Handle.new(audio_library, Fiddle::RTLD_NOW)
155
155
  audio_symbols = %w[
156
156
  cohere_audio_ffmpeg_probe
157
+ cohere_audio_ffmpeg_versions
157
158
  cohere_audio_ffmpeg_decode
158
159
  cohere_audio_ffmpeg_duration
159
160
  cohere_audio_ffmpeg_cancel
@@ -171,7 +172,32 @@ probe_status = probe.call(diagnostic, 1_024)
171
172
  raise "audio probe returned an invalid status" unless [0, 1].include?(probe_status)
172
173
  raise "audio probe returned no diagnostic" if diagnostic.to_s.empty?
173
174
 
175
+ versions = Fiddle::Function.new(
176
+ audio_handle["cohere_audio_ffmpeg_versions"],
177
+ [Fiddle::TYPE_VOIDP, Fiddle::TYPE_SIZE_T],
178
+ Fiddle::TYPE_INT
179
+ )
180
+ raise "audio versions accepted a null output" unless versions.call(0, 0) == 2
181
+
174
182
  if probe_status.zero?
183
+ tuple_bytes = 4 * Fiddle::SIZEOF_INT
184
+ tuple = Fiddle::Pointer.malloc(tuple_bytes, Fiddle::RUBY_FREE)
185
+ tuple[0, tuple_bytes] = [0, 0, 0, 0].pack("i!*")
186
+ raise "audio versions call failed" unless versions.call(tuple, 4).zero?
187
+
188
+ format_major, codec_major, util_major, resample_major = tuple[0, tuple_bytes].unpack("i!4")
189
+ raise "audio versions returned an incompatible format/codec tuple" unless format_major == codec_major
190
+ raise "audio versions returned an incompatible avutil major" unless util_major == format_major - 2
191
+
192
+ expected_resample = if format_major == 58
193
+ 3
194
+ elsif format_major <= 60
195
+ 4
196
+ else
197
+ format_major - 56
198
+ end
199
+ raise "audio versions returned an incompatible swresample major" unless resample_major == expected_resample
200
+
175
201
  duration = Fiddle::Function.new(
176
202
  audio_handle["cohere_audio_ffmpeg_duration"],
177
203
  [Fiddle::TYPE_VOIDP, Fiddle::TYPE_VOIDP, Fiddle::TYPE_VOIDP, Fiddle::TYPE_SIZE_T],
@@ -231,6 +231,7 @@ module Cohere
231
231
  WINDOW_SAMPLES = WINDOW_SECONDS * SAMPLE_RATE
232
232
  CONTEXT_SAMPLES = CONTEXT_SECONDS * SAMPLE_RATE
233
233
  INPUT_SAMPLES = WINDOW_SAMPLES + (2 * CONTEXT_SAMPLES)
234
+ MINIMUM_INPUT_SAMPLES = 400
234
235
  WINDOW_FRAMES = WINDOW_SAMPLES / INPUTS_TO_LOGITS_RATIO
235
236
  CONTEXT_FRAMES = CONTEXT_SAMPLES / INPUTS_TO_LOGITS_RATIO
236
237
  STRIDE_MS = INPUTS_TO_LOGITS_RATIO * 1_000.0 / SAMPLE_RATE
@@ -289,6 +290,7 @@ module Cohere
289
290
  started = monotonic
290
291
  samples = mono_float32(audio)
291
292
  raise ArgumentError, "Cannot compute CTC emissions for empty audio" if samples.empty?
293
+ return [compute_direct_emissions(samples), STRIDE_MS] if samples.length < WINDOW_SAMPLES
292
294
 
293
295
  total_windows = (samples.length + WINDOW_SAMPLES - 1) / WINDOW_SAMPLES
294
296
  extension_samples = (total_windows * WINDOW_SAMPLES) - samples.length
@@ -320,10 +322,7 @@ module Cohere
320
322
  end
321
323
 
322
324
  class_count = batch_log_probs.shape[1]
323
- unless class_count == VOCABULARY.length
324
- raise TranscriptionRuntimeError,
325
- "MMS aligner returned #{class_count} classes; expected #{VOCABULARY.length}"
326
- end
325
+ validate_class_count!(class_count)
327
326
  emissions ||= Numo::SFloat.zeros(frame_count, class_count + 1)
328
327
  if first_window + window_count == total_windows && extension_frames.positive?
329
328
  kept = batch_log_probs.shape[0] - extension_frames
@@ -440,6 +439,29 @@ module Cohere
440
439
  batch
441
440
  end
442
441
 
442
+ # The pinned ctc-forced-aligner passes sub-30-second waveforms to MMS
443
+ # directly, without window context or tail padding. Its Wav2Vec2 feature
444
+ # extractor needs at least its 400-sample convolutional receptive field,
445
+ # so only shorter inputs receive the minimum right padding needed to run.
446
+ def compute_direct_emissions(audio)
447
+ input_samples = [audio.length, MINIMUM_INPUT_SAMPLES].max
448
+ input = Numo::SFloat.zeros(1, input_samples)
449
+ input[0, 0...audio.length] = audio
450
+ logits = session.run(input)
451
+ logits = Numo::SFloat.cast(logits) unless logits.is_a?(Numo::NArray)
452
+ unless logits.ndim == 3 && logits.shape[0] == 1 && logits.shape[1].positive?
453
+ raise TranscriptionRuntimeError,
454
+ "MMS aligner returned invalid direct logits shape #{logits.shape.inspect}"
455
+ end
456
+
457
+ class_count = logits.shape[2]
458
+ validate_class_count!(class_count)
459
+ log_probs = log_softmax(logits.reshape(logits.shape[1], class_count))
460
+ emissions = Numo::SFloat.zeros(log_probs.shape[0], class_count + 1)
461
+ emissions[true, 0...class_count] = log_probs
462
+ emissions
463
+ end
464
+
443
465
  def crop_and_normalize(logits, expected_batch)
444
466
  logits = Numo::SFloat.cast(logits) unless logits.is_a?(Numo::NArray)
445
467
  unless logits.ndim == 3 && logits.shape[0] == expected_batch &&
@@ -451,12 +473,23 @@ module Cohere
451
473
  classes = logits.shape[2]
452
474
  cropped = logits[true, CONTEXT_FRAMES...(CONTEXT_FRAMES + WINDOW_FRAMES), true]
453
475
  .reshape(expected_batch * WINDOW_FRAMES, classes)
454
- maxima = cropped.max(1).reshape(cropped.shape[0], 1)
455
- shifted = cropped - maxima
456
- denominators = Numo::NMath.log(Numo::NMath.exp(shifted).sum(1)).reshape(cropped.shape[0], 1)
476
+ log_softmax(cropped)
477
+ end
478
+
479
+ def log_softmax(logits)
480
+ maxima = logits.max(1).reshape(logits.shape[0], 1)
481
+ shifted = logits - maxima
482
+ denominators = Numo::NMath.log(Numo::NMath.exp(shifted).sum(1)).reshape(logits.shape[0], 1)
457
483
  (shifted - denominators).cast_to(Numo::SFloat)
458
484
  end
459
485
 
486
+ def validate_class_count!(class_count)
487
+ return if class_count == VOCABULARY.length
488
+
489
+ raise TranscriptionRuntimeError,
490
+ "MMS aligner returned #{class_count} classes; expected #{VOCABULARY.length}"
491
+ end
492
+
460
493
  def uniform_fallback(text, start_time, end_time, segment_index)
461
494
  Output::Timing.uniform_words(
462
495
  text, start_time, end_time, segment_index, "uniform_fallback"
@@ -4,6 +4,8 @@ require "etc"
4
4
  require "fiddle"
5
5
  require "rbconfig"
6
6
  require_relative "../errors"
7
+ require_relative "../internal/interruptible_native_call"
8
+ require_relative "../internal/session_ownership"
7
9
  require_relative "../python_text"
8
10
  require_relative "failure_policy"
9
11
 
@@ -297,69 +299,38 @@ module Cohere
297
299
  4 => :error
298
300
  }.freeze
299
301
 
300
- # Keeps the native pointer independently of the Ruby session wrapper so
301
- # ObjectSpace cleanup never retains the object it is meant to collect.
302
- # Taking the pointer before calling the foreign close function makes
303
- # explicit close, constructor rollback, and finalization idempotent.
304
- class SessionOwnership
305
- def initialize(library)
306
- @library = library
307
- @mutex = Mutex.new
308
- @session = nil
309
- end
310
-
311
- def install(session)
312
- @mutex.synchronize do
313
- raise TranscriptionRuntimeError, "Native session ownership is already installed" if @session
314
-
315
- @session = session
316
- end
317
- end
318
-
319
- def close
320
- session = @mutex.synchronize do
321
- current = @session
322
- @session = nil
323
- current
324
- end
325
- return unless session
326
-
327
- @library.call(:session_close, session)
328
- nil
329
- end
330
-
331
- def finalize
332
- close
333
- rescue Exception # rubocop:disable Lint/RescueException -- finalizers must not escape during GC or shutdown
334
- nil
302
+ # A callable retained by detached ownership state. It keeps the native
303
+ # library alive, but never captures the NativeSession being finalized.
304
+ NativeSessionCloser = Data.define(:library) do
305
+ def call(session)
306
+ library.call(:session_close, session)
335
307
  end
336
308
  end
337
- private_constant :SessionOwnership
338
-
339
- def self.finalizer_for(ownership)
340
- proc { |_object_id| ownership.finalize }
341
- end
342
- private_class_method :finalizer_for
309
+ private_constant :NativeSessionCloser
343
310
 
344
311
  attr_reader :backend, :batch_capacity, :compute_backend, :device, :last_batch_metrics, :model_path
345
312
 
346
313
  def initialize(model_path, options, threads: nil, library: NativeLibrary.load)
314
+ initialized = false
347
315
  @library = library
348
316
  @model_path = Pathname.new(model_path).expand_path.freeze
349
317
  raise TranscriptionRuntimeError, "Native Dense model does not exist: #{@model_path}" unless @model_path.file?
350
318
 
351
319
  @mutex = Mutex.new
352
320
  @closed = false
353
- @session_ownership = SessionOwnership.new(@library)
321
+ @session_ownership = Internal::SessionOwnership.new(
322
+ close: NativeSessionCloser.new(@library),
323
+ installed_error: "Native session ownership is already installed"
324
+ )
354
325
  ObjectSpace.define_finalizer(
355
326
  self,
356
- self.class.send(:finalizer_for, @session_ownership)
327
+ Internal::SessionOwnership.finalizer_for(@session_ownership)
357
328
  )
358
329
  thread_count = normalize_threads(threads)
359
330
  # An asynchronous exception may become deliverable immediately after
360
331
  # the foreign open returns. Defer it until the pointer belongs to the
361
- # independent cleanup state, then let the constructor rescue close it.
362
- Thread.handle_interrupt(Exception => :never) do
332
+ # independent cleanup state, then let constructor teardown close it.
333
+ Thread.handle_interrupt(Object => :never) do
363
334
  session = @library.open_session(
364
335
  model_path: @model_path,
365
336
  device: options.device.to_s,
@@ -397,25 +368,29 @@ module Cohere
397
368
  close
398
369
  raise TranscriptionRuntimeError, "Native runtime reported an invalid Cohere batch capacity"
399
370
  end
400
- rescue Exception # rubocop:disable Lint/RescueException -- native ownership must roll back on Interrupt too
401
- begin
402
- close if defined?(@session_ownership)
403
- rescue Exception # rubocop:disable Lint/RescueException -- preserve the constructor failure
404
- nil
371
+ initialized = true
372
+ ensure
373
+ unless initialized
374
+ Thread.handle_interrupt(Object => :never) do
375
+ close if defined?(@session_ownership)
376
+ rescue Exception # rubocop:disable Lint/RescueException -- preserve constructor termination
377
+ nil
378
+ end
405
379
  end
406
- raise
407
380
  end
408
381
 
409
382
  def transcribe(samples, language:, offset: 0.0, max_new_tokens: nil)
410
383
  @mutex.synchronize do
411
384
  ensure_open!
412
385
  @last_batch_metrics = nil
413
- generation_limit = max_new_tokens.nil? ? @default_max_new_tokens : Integer(max_new_tokens)
414
- set_generation_limit!(generation_limit)
415
386
  binary, sample_count = float_samples(samples)
387
+ raise ArgumentError, "Audio segment must not be empty" if sample_count.zero?
416
388
  raise TranscriptionRuntimeError, "Audio segment is too large for the native ABI" \
417
389
  if sample_count > MAX_NATIVE_SAMPLE_COUNT
418
390
 
391
+ generation_limit = max_new_tokens.nil? ? @default_max_new_tokens : Integer(max_new_tokens)
392
+ set_generation_limit!(generation_limit)
393
+
419
394
  run_native_inference do
420
395
  result = @library.call(
421
396
  :session_transcribe_lang,
@@ -452,16 +427,19 @@ module Cohere
452
427
  @mutex.synchronize do
453
428
  ensure_open!
454
429
  @last_batch_metrics = nil
455
- generation_limit = max_new_tokens.nil? ? @default_max_new_tokens : Integer(max_new_tokens)
456
- set_generation_limit!(generation_limit)
457
-
458
430
  buffers_and_counts = sample_batches.map { |samples| float_samples(samples) }
431
+ empty_lane = buffers_and_counts.index { |_binary, count| count.zero? }
432
+ raise ArgumentError, "Audio batch row #{empty_lane} must not be empty" if empty_lane
433
+
459
434
  oversized_lane = buffers_and_counts.index { |_binary, count| count > MAX_NATIVE_SAMPLE_COUNT }
460
435
  if oversized_lane
461
436
  raise TranscriptionRuntimeError,
462
437
  "Audio batch row #{oversized_lane} is too large for the native ABI"
463
438
  end
464
439
 
440
+ generation_limit = max_new_tokens.nil? ? @default_max_new_tokens : Integer(max_new_tokens)
441
+ set_generation_limit!(generation_limit)
442
+
465
443
  run_native_inference do
466
444
  pointers = buffers_and_counts.map { |binary, _count| Fiddle::Pointer[binary] }
467
445
  pointer_table = pointers.map(&:to_i).pack("J*")
@@ -558,51 +536,19 @@ module Cohere
558
536
 
559
537
  # Ruby cannot deliver Thread#raise (including SIGINT's Interrupt) while
560
538
  # the receiving thread is blocked inside Fiddle. Keep the caller at an
561
- # interruptible join while a private worker owns the foreign call. The
539
+ # interruptible join while a dedicated worker owns the foreign call. The
562
540
  # caller still holds @mutex, so close and another inference cannot race
563
541
  # the session; cancellation is the only concurrent C ABI operation.
564
- def run_native_inference
565
- outcome = Queue.new
566
- worker = nil
567
- Thread.handle_interrupt(Exception => :on_blocking) do
568
- worker = Thread.new do
569
- outcome << [:returned, yield]
570
- rescue Exception => e # rubocop:disable Lint/RescueException -- transfer native cancellation intact
571
- outcome << [:raised, e]
572
- end
573
- worker.report_on_exception = false
574
-
575
- begin
576
- worker.join
577
- rescue Exception => e # rubocop:disable Lint/RescueException -- caller cancellation must win
578
- cancel_and_hard_join(worker)
579
- raise e
580
- end
581
- end
582
-
583
- status, value = begin
584
- outcome.pop(true)
585
- rescue ThreadError
586
- raise ExecutionError.new(
542
+ def run_native_inference(&)
543
+ Internal::InterruptibleNativeCall.run(
544
+ cancel: -> { @library.call(:session_cancel, @session) },
545
+ join_interval: CANCELLATION_JOIN_INTERVAL,
546
+ missing_outcome: ExecutionError.new(
587
547
  "Native Cohere inference worker exited without reporting an outcome",
588
548
  failure_kind: :fatal
589
- )
590
- end
591
- raise value if status == :raised
592
-
593
- value
594
- end
595
-
596
- # A cancellation can arrive after Thread.new but before the worker has
597
- # entered the C function. Retry the non-poisoning session cancel until
598
- # the worker exits; never kill a thread that may still own ggml state.
599
- def cancel_and_hard_join(worker)
600
- loop do
601
- @library.call(:session_cancel, @session)
602
- return if worker.join(CANCELLATION_JOIN_INTERVAL)
603
- rescue Exception # rubocop:disable Lint/RescueException -- preserve the first caller exception
604
- next
605
- end
549
+ ),
550
+ &
551
+ )
606
552
  end
607
553
 
608
554
  def canonical_device(name)