Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 2 additions & 6 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,8 @@ Thank you for your interest in contributing to Cactus! This document covers the
1. Fork the repository and create a branch from `main`.
2. Make your changes, keeping the scope focused on the relevant GitHub issue.
3. Run `cactus test` to verify your changes build and pass all tests.
4. Run `cactus test --suite performance` if your changes affect performance-critical paths.
5. Update documentation if your changes affect the public API or user-facing behavior.
6. Submit a pull request with a clear description of what you changed and why.
4. Update documentation if your changes affect the public API or user-facing behavior.
5. Submit a pull request with a clear description of what you changed and why.

## Testing

Expand All @@ -40,9 +39,6 @@ cactus test --ios
# Run tests on a connected Android device
cactus test --android

# Run performance tests
cactus test --suite performance

# Test a specific model
cactus test --model LiquidAI/LFM2-VL-450M
```
Expand Down
22 changes: 12 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,9 @@ Example response from Gemma3-270m
"cloud_handoff": false, // true if cloud model used
"response": "Hi there!",
"function_calls": [], // parsed tool calls
"segments": [], // transcription segments (empty for chat)
"confidence": 0.8193, // model confidence
"confidence_threshold": 0.7, // resolved handoff threshold (model-dependent)
"time_to_first_token_ms": 45.23,
"total_time_ms": 163.67,
"prefill_tps": 1621.89,
Expand Down Expand Up @@ -192,9 +194,9 @@ graph.hard_reset();
│ --status show key status │
│ --clear remove saved key │
│ │
│ cactus run <model|path> run a model (downloads if needed) │
│ cactus run [model|path] run a model (downloads if needed) │
│ --bits 1|2|3|4 CQ quantization (default: 4) │
│ --platform cpu|apple target accelerator (default: cpu) │
│ --platform auto|cpu|apple target accelerator (default: auto) │
│ --image <path> image file for VLM inference │
│ --audio <path> audio file for audio chat │
│ --system <prompt> system prompt │
Expand All @@ -203,15 +205,15 @@ graph.hard_reset();
│ --token <token> HuggingFace token (gated models) │
│ --reconvert force local convert+transpile fallback │
│ │
│ cactus transcribe [model] transcribe audio with a model │
│ --file <audio.wav> audio file to transcribe (required) │
│ cactus transcribe [model] live microphone transcription with a model│
│ --file <audio.wav> audio file to transcribe (WAV) │
│ --language <code> language code (default: en) │
│ --token <token> HuggingFace token (gated models) │
│ --reconvert force reconversion from source │
│ │
│ cactus download <model> download a pre-built bundle │
│ cactus download [model] download a pre-built bundle │
│ --bits 1|2|3|4 CQ quantization (default: 4) │
│ --platform cpu|apple target accelerator (default: cpu) │
│ --platform auto|cpu|apple target accelerator (default: auto) │
│ --token <token> HuggingFace token │
│ │
│ cactus convert <model> [dir] convert HuggingFace weights to CQ │
Expand Down Expand Up @@ -241,15 +243,15 @@ graph.hard_reset();
│ (default: all) │
│ --model <hf-id> default: LiquidAI/LFM2-VL-450M │
│ --transcription-model <hf-id> default: openai/whisper-base │
│ --suite <name> run a single test suite (resolved │
│ across components; e.g. performance │
│ → kernels + graph, llm → engine) │
│ --suite <name> run a single test suite by name │
│ (resolved across components, │
│ e.g. llm → engine) │
│ --list list components and suites │
│ --ios run on connected iPhone │
│ --android run on connected Android │
│ --enable-telemetry send cloud telemetry (off by default) │
│ │
│ cactus clean delete build artifacts │
│ cactus clean delete build artifacts, weights, venv │
│ cactus --help show this help │
│ │
└────────────────────────────────────────────────────────────────────────────────┘
Expand Down
48 changes: 48 additions & 0 deletions android/cactus_jni.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,54 @@ Java_com_cactus_CactusJNI_nativeTranscribe(JNIEnv* env, jobject, jlong handle,
return result;
}

JNIEXPORT jlong JNICALL
Java_com_cactus_CactusJNI_nativeStreamTranscribeStart(JNIEnv* env, jobject, jlong handle,
jstring optionsJson) {
const char* options = optionsJson ? env->GetStringUTFChars(optionsJson, nullptr) : nullptr;
cactus_stream_transcribe_t stream = cactus_stream_transcribe_start(
reinterpret_cast<cactus_model_t>(handle), options);
if (options) env->ReleaseStringUTFChars(optionsJson, options);
return reinterpret_cast<jlong>(stream);
}

JNIEXPORT jint JNICALL
Java_com_cactus_CactusJNI_nativeStreamTranscribeProcess(JNIEnv* env, jobject, jlong stream,
jbyteArray pcmData,
jbyteArray responseBuffer) {
jbyte* pcmBytes = pcmData ? env->GetByteArrayElements(pcmData, nullptr) : nullptr;
size_t pcmSize = pcmData ? static_cast<size_t>(env->GetArrayLength(pcmData)) : 0;

jsize bufSize = env->GetArrayLength(responseBuffer);
jbyte* buf = env->GetByteArrayElements(responseBuffer, nullptr);

int result = cactus_stream_transcribe_process(
reinterpret_cast<cactus_stream_transcribe_t>(stream),
reinterpret_cast<const uint8_t*>(pcmBytes), pcmSize,
reinterpret_cast<char*>(buf), static_cast<size_t>(bufSize)
);

env->ReleaseByteArrayElements(responseBuffer, buf, 0);
if (pcmBytes) env->ReleaseByteArrayElements(pcmData, pcmBytes, JNI_ABORT);

return result;
}

JNIEXPORT jint JNICALL
Java_com_cactus_CactusJNI_nativeStreamTranscribeStop(JNIEnv* env, jobject, jlong stream,
jbyteArray responseBuffer) {
jsize bufSize = env->GetArrayLength(responseBuffer);
jbyte* buf = env->GetByteArrayElements(responseBuffer, nullptr);

int result = cactus_stream_transcribe_stop(
reinterpret_cast<cactus_stream_transcribe_t>(stream),
reinterpret_cast<char*>(buf), static_cast<size_t>(bufSize)
);

env->ReleaseByteArrayElements(responseBuffer, buf, 0);

return result;
}

JNIEXPORT jint JNICALL
Java_com_cactus_CactusJNI_nativeEmbed(JNIEnv* env, jobject, jlong handle,
jstring text, jfloatArray embeddingsBuffer,
Expand Down
20 changes: 20 additions & 0 deletions bindings/flutter/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ cactus build --android

<!-- --8<-- [start:example] -->
```dart
import 'dart:ffi';
import 'cactus.dart';
import 'package:ffi/ffi.dart';

Expand All @@ -40,3 +41,22 @@ calloc.free(buf);
cactusDestroy(model);
```
<!-- --8<-- [end:example] -->

## Streaming transcription

Push 16 kHz mono PCM16 as it arrives; each call returns `{"success":true,"confirmed":...,"pending":...}` (`confirmed` is final, `pending` is the volatile tail).

```dart
final opts = '{"language":"en"}'.toNativeUtf8();
final stream = cactusStreamTranscribeStart(model, opts);
final out = calloc<Int8>(65536);
for (final chunk in pcmChunks) { // each: 16 kHz mono PCM16 bytes
final p = calloc<Uint8>(chunk.length)..asTypedList(chunk.length).setAll(0, chunk);
cactusStreamTranscribeProcess(stream, p, chunk.length, out.cast(), 65536);
calloc.free(p);
// parse {"confirmed":...,"pending":...} from out.cast<Utf8>().toDartString()
}
cactusStreamTranscribeStop(stream, out.cast(), 65536);
calloc.free(opts);
calloc.free(out);
```
33 changes: 33 additions & 0 deletions bindings/flutter/cactus.dart
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ final DynamicLibrary _lib = (Platform.isAndroid || Platform.isLinux)

typedef CactusModelT = Pointer<Void>;
typedef CactusIndexT = Pointer<Void>;
typedef CactusStreamTranscribeT = Pointer<Void>;

typedef TokenCallbackNative = Void Function(
Pointer<Utf8> token, Uint32 tokenId, Pointer<Void> userData);
Expand Down Expand Up @@ -146,6 +147,38 @@ final cactusTranscribe =
_lib.lookupFunction<_CactusTranscribeN, _CactusTranscribeD>(
'cactus_transcribe');

typedef _CactusStreamTranscribeStartN = Pointer<Void> Function(
Pointer<Void> model, Pointer<Utf8> optionsJson);
typedef _CactusStreamTranscribeStartD = Pointer<Void> Function(
Pointer<Void> model, Pointer<Utf8> optionsJson);
final cactusStreamTranscribeStart = _lib.lookupFunction<
_CactusStreamTranscribeStartN,
_CactusStreamTranscribeStartD>('cactus_stream_transcribe_start');

typedef _CactusStreamTranscribeProcessN = Int32 Function(
Pointer<Void> stream,
Pointer<Uint8> pcmBuffer,
IntPtr pcmBufferSize,
Pointer<Utf8> responseBuffer,
IntPtr bufferSize);
typedef _CactusStreamTranscribeProcessD = int Function(
Pointer<Void> stream,
Pointer<Uint8> pcmBuffer,
int pcmBufferSize,
Pointer<Utf8> responseBuffer,
int bufferSize);
final cactusStreamTranscribeProcess = _lib.lookupFunction<
_CactusStreamTranscribeProcessN,
_CactusStreamTranscribeProcessD>('cactus_stream_transcribe_process');

typedef _CactusStreamTranscribeStopN = Int32 Function(
Pointer<Void> stream, Pointer<Utf8> responseBuffer, IntPtr bufferSize);
typedef _CactusStreamTranscribeStopD = int Function(
Pointer<Void> stream, Pointer<Utf8> responseBuffer, int bufferSize);
final cactusStreamTranscribeStop = _lib.lookupFunction<
_CactusStreamTranscribeStopN,
_CactusStreamTranscribeStopD>('cactus_stream_transcribe_stop');

typedef _CactusEmbedN = Int32 Function(
Pointer<Void> model,
Pointer<Utf8> text,
Expand Down
18 changes: 18 additions & 0 deletions bindings/kotlin/Cactus.android.kt
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,24 @@ actual fun cactusTranscribe(handle: Long, audioPath: String?, prompt: String, op
return buffer.decodeToString().trimEnd('\u0000')
}

actual fun cactusStreamTranscribeStart(handle: Long, optionsJson: String?): Long {
val stream = CactusJNI.nativeStreamTranscribeStart(handle, optionsJson)
if (stream == 0L) throw RuntimeException(CactusJNI.nativeGetLastError().ifEmpty { "Failed to start streaming transcription" })
return stream
}

actual fun cactusStreamTranscribeProcess(stream: Long, pcmData: ByteArray?): String {
val buffer = ByteArray(65536)
check(CactusJNI.nativeStreamTranscribeProcess(stream, pcmData, buffer))
return buffer.decodeToString().trimEnd('\u0000')
}

actual fun cactusStreamTranscribeStop(stream: Long): String {
val buffer = ByteArray(65536)
check(CactusJNI.nativeStreamTranscribeStop(stream, buffer))
return buffer.decodeToString().trimEnd('\u0000')
}

actual fun cactusEmbed(handle: Long, text: String, normalize: Boolean): FloatArray {
val buffer = FloatArray(4096)
val outDim = LongArray(1)
Expand Down
3 changes: 3 additions & 0 deletions bindings/kotlin/Cactus.common.kt
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ expect fun cactusPrefill(handle: Long, messagesJson: String, optionsJson: String
expect fun cactusTokenize(handle: Long, text: String): IntArray
expect fun cactusScoreWindow(handle: Long, tokens: IntArray, start: Long, end: Long, context: Long): String
expect fun cactusTranscribe(handle: Long, audioPath: String?, prompt: String, optionsJson: String?, callback: CactusTokenCallback?, pcmData: ByteArray?): String
expect fun cactusStreamTranscribeStart(handle: Long, optionsJson: String?): Long
expect fun cactusStreamTranscribeProcess(stream: Long, pcmData: ByteArray?): String
expect fun cactusStreamTranscribeStop(stream: Long): String
expect fun cactusEmbed(handle: Long, text: String, normalize: Boolean): FloatArray
expect fun cactusImageEmbed(handle: Long, imagePath: String): FloatArray
expect fun cactusAudioEmbed(handle: Long, audioPath: String): FloatArray
Expand Down
34 changes: 34 additions & 0 deletions bindings/kotlin/Cactus.ios.kt
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,40 @@ actual fun cactusTranscribe(handle: Long, audioPath: String?, prompt: String, op
}
}

@OptIn(ExperimentalForeignApi::class)
actual fun cactusStreamTranscribeStart(handle: Long, optionsJson: String?): Long {
val stream = cactus_stream_transcribe_start(handle.toCPointer(), optionsJson)
?: throw RuntimeException(cactus_get_last_error()?.toKString() ?: "Failed to start streaming transcription")
return stream.rawValue.toLong()
}

@OptIn(ExperimentalForeignApi::class)
actual fun cactusStreamTranscribeProcess(stream: Long, pcmData: ByteArray?): String {
memScoped {
val buffer = allocArray<ByteVar>(65536)
val pcmPtr = pcmData?.refTo(0)?.getPointer(this)
val result = cactus_stream_transcribe_process(
stream.toCPointer(),
pcmPtr?.reinterpret(),
pcmData?.size?.toULong() ?: 0u,
buffer,
65536u
)
if (result < 0) throw RuntimeException(cactus_get_last_error()?.toKString() ?: "Unknown error")
return buffer.toKString()
}
}

@OptIn(ExperimentalForeignApi::class)
actual fun cactusStreamTranscribeStop(stream: Long): String {
memScoped {
val buffer = allocArray<ByteVar>(65536)
val result = cactus_stream_transcribe_stop(stream.toCPointer(), buffer, 65536u)
if (result < 0) throw RuntimeException(cactus_get_last_error()?.toKString() ?: "Unknown error")
return buffer.toKString()
}
}

@OptIn(ExperimentalForeignApi::class)
actual fun cactusEmbed(handle: Long, text: String, normalize: Boolean): FloatArray {
memScoped {
Expand Down
3 changes: 3 additions & 0 deletions bindings/kotlin/Cactus.kt
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ object CactusJNI {
@JvmStatic external fun nativeTokenize(handle: Long, text: String, tokenBuffer: IntArray, outTokenLen: LongArray): Int
@JvmStatic external fun nativeScoreWindow(handle: Long, tokens: IntArray, start: Long, end: Long, context: Long, responseBuffer: ByteArray): Int
@JvmStatic external fun nativeTranscribe(handle: Long, audioPath: String?, prompt: String, responseBuffer: ByteArray, optionsJson: String?, callback: CactusTokenCallback?, pcmData: ByteArray?): Int
@JvmStatic external fun nativeStreamTranscribeStart(handle: Long, optionsJson: String?): Long
@JvmStatic external fun nativeStreamTranscribeProcess(stream: Long, pcmData: ByteArray?, responseBuffer: ByteArray): Int
@JvmStatic external fun nativeStreamTranscribeStop(stream: Long, responseBuffer: ByteArray): Int
@JvmStatic external fun nativeEmbed(handle: Long, text: String, embeddingsBuffer: FloatArray, outEmbeddingDim: LongArray, normalize: Boolean): Int
@JvmStatic external fun nativeImageEmbed(handle: Long, imagePath: String, embeddingsBuffer: FloatArray, outEmbeddingDim: LongArray): Int
@JvmStatic external fun nativeAudioEmbed(handle: Long, audioPath: String, embeddingsBuffer: FloatArray, outEmbeddingDim: LongArray): Int
Expand Down
18 changes: 18 additions & 0 deletions bindings/kotlin/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,18 @@ CactusJNI.nativeDestroy(handle)
```
<!-- --8<-- [end:example] -->

Streaming transcription — push 16 kHz mono PCM16, read `{"success":true,"confirmed":...,"pending":...}` back each call:

```kotlin
val stream = CactusJNI.nativeStreamTranscribeStart(handle, "{\"language\":\"en\"}")
val out = ByteArray(65536)
for (chunk in pcmChunks) { // each: 16 kHz mono PCM16 bytes
CactusJNI.nativeStreamTranscribeProcess(stream, chunk, out)
// parse confirmed/pending from String(out, 0, out.indexOf(0))
}
CactusJNI.nativeStreamTranscribeStop(stream, out)
```

## Kotlin Multiplatform

```
Expand Down Expand Up @@ -64,5 +76,11 @@ kotlin {
```kotlin
val model = cactusInit("/path/to/model", null, false)
val result = cactusComplete(model, messagesJson, null, null, null, null)

// streaming transcription: start, feed PCM16 chunks, stop
val stream = cactusStreamTranscribeStart(model, "{\"language\":\"en\"}")
val out = cactusStreamTranscribeProcess(stream, pcmChunk) // {"success":true,"confirmed":...,"pending":...}
val tail = cactusStreamTranscribeStop(stream)

cactusDestroy(model)
```
4 changes: 2 additions & 2 deletions bindings/python/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@
ctypes FFI to `cactus_engine.h`. The binding module lives at
`/python/cactus/bindings/cactus.py`.

It loads `libcactus_engine.{so,dylib}` from `/python/cactus/bin/`, populated by
`cactus build --python`.
It loads `libcactus_engine.{so,dylib}` from `/python/cactus/bindings/lib/` (bundled)
or `cactus-engine/build/` (dev build), populated by `cactus build --python`.

For installation, the high-level API, the CLI, the server, and the transpiler,
see [`/python/README.md`](/python/).
13 changes: 13 additions & 0 deletions bindings/react-native/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,16 @@ const result = await Cactus.complete(handle, messagesJson, null, null, null, fal
await Cactus.destroy(handle);
```
<!-- --8<-- [end:example] -->

## Streaming transcription

Push 16 kHz mono PCM16 (base64) as it arrives; each call returns `{"success":true,"confirmed":...,"pending":...}` (`confirmed` is final, `pending` is the volatile tail).

```ts
const stream = await Cactus.streamTranscribeStart(handle, '{"language":"en"}');
for (const chunkBase64 of pcmChunks) {
const json = await Cactus.streamTranscribeProcess(stream, chunkBase64);
// parse json -> append "confirmed", show "pending" as a live preview
}
const tail = await Cactus.streamTranscribeStop(stream);
```
Loading
Loading