Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
75 changes: 75 additions & 0 deletions shared/api/gemma4_audio_features.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -344,4 +344,79 @@ class Gemma4LogMel {
std::vector<float> mel_filters_; // (n_freq x feature_size), row-major
};

// Gemma 4 *unified* (encoder-free, gemma-4-12B) audio feature extraction.
//
// Unlike Gemma4LogMel (128-dim USM log-mel), the unified model has no audio
// encoder: each audio soft token is simply a fixed-length chunk of the raw
// 16 kHz waveform. This op reproduces HuggingFace
// ``Gemma4UnifiedAudioFeatureExtractor._extract_waveform_features`` exactly:
// zero-pad the waveform to a multiple of ``audio_samples_per_token`` and
// reshape it into ``(num_tokens, audio_samples_per_token)`` frames.
//
// Pipeline: AudioDecoder -> Gemma4UnifiedAudioFrames
//
// Inputs: float (1, num_samples) — mono PCM at `sampling_rate` Hz
// Outputs: float (num_tokens, audio_samples_per_token) — raw waveform frames
//
// The frame-level mask is intentionally not emitted here: for the single-clip
// inference path the downstream processor fills an all-true mask, and the
// batch framework zero-pads ragged clips when stacking.
class Gemma4UnifiedAudioFrames {
public:
Gemma4UnifiedAudioFrames() = default;

OrtxStatus Compute(const ortc::Tensor<float>& pcm_input,
ortc::Tensor<float>& frames_out) {
const auto& pcm_shape = pcm_input.Shape();
if (pcm_shape.size() != 2 || pcm_shape[0] != 1) {
return {kOrtxErrorInvalidArgument,
"[Gemma4UnifiedAudioFrames]: expected (1, num_samples) float input"};
}

const int64_t num_samples = pcm_shape[1];
const int64_t spt = audio_samples_per_token_;
// Zero-pad to a whole number of frames (ceil division), matching HF's
// ``pad_len = (-len(waveform)) % audio_samples_per_token``.
const int64_t num_tokens = (num_samples + spt - 1) / spt;

float* out = frames_out.Allocate({num_tokens, spt});
if (num_tokens == 0) {
return {};
}
// Fill the (possibly padded) tail of the last frame with the padding value,
// then copy the real samples over the front.
std::fill(out, out + static_cast<size_t>(num_tokens) * spt, padding_value_);
std::copy(pcm_input.Data(), pcm_input.Data() + num_samples, out);
return {};
}

template <typename DictT>
OrtxStatus Init(const DictT& attrs) {
for (const auto& [key, value] : attrs) {
if (key == "audio_samples_per_token" || key == "feature_size") {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

for audio_samples_per_token and feature_size aliasing: if both keys are provided with different values, the current behavior silently takes whichever appears last. can we explicitly detect and reject conflicting values? silent override here could hide config mistakes and produce unexpected frame shapes.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 920f54b. Gemma4UnifiedAudioFrames::Init now tracks audio_samples_per_token and feature_size separately and, if both are provided with different values, returns kOrtxErrorInvalidArgument ("they are aliases and must match") instead of silently taking the last one.

// ``feature_size`` is accepted as an alias: HF sets feature_size ==
// audio_samples_per_token (both default to 640).
audio_samples_per_token_ = std::get<int64_t>(value);
} else if (key == "sampling_rate") {
sampling_rate_ = std::get<int64_t>(value);
} else if (key == "padding_value") {
padding_value_ = static_cast<float>(std::get<double>(value));
} else {
return {kOrtxErrorInvalidArgument,
"[Gemma4UnifiedAudioFrames]: unknown attribute '" + key + "'"};
}
}
if (audio_samples_per_token_ <= 0) {
return {kOrtxErrorInvalidArgument,
"[Gemma4UnifiedAudioFrames]: audio_samples_per_token must be positive"};
}
return {};
}
Comment thread
justinchuby marked this conversation as resolved.

private:
int64_t audio_samples_per_token_ = 640; // 640 samples = 40 ms @ 16 kHz
int64_t sampling_rate_ = 16000;
float padding_value_ = 0.0f;
};

} // namespace ort_extensions
3 changes: 2 additions & 1 deletion shared/api/speech_extractor.cc
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@ Operation::KernelRegistry SpeechFeatureExtractor::kernel_registry_ = {
{"NemoLogMel", []() { return CreateKernelInstance(&NemoLogMel::Compute); }},
{"PerFeatureNormalize", []() { return CreateKernelInstance(&PerFeatureNormalize::Compute); }},
{"Phi4AudioEmbed", []() { return CreateKernelInstance(&Phi4AudioEmbed::Compute); }},
{"Gemma4LogMel", []() { return CreateKernelInstance(&Gemma4LogMel::Compute); }}};
{"Gemma4LogMel", []() { return CreateKernelInstance(&Gemma4LogMel::Compute); }},
{"Gemma4UnifiedAudioFrames", []() { return CreateKernelInstance(&Gemma4UnifiedAudioFrames::Compute); }}};

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do you think we can have a more generic audio processor for gemma? so say instead of having both Gemma4LogMel and Gemma4UnifiedAudioFrames kernels, we maybe have a Gemma4Audio kernel (or similar), that can take JSON options in order to determine which pass to use?

for instance, with the Qwen-2.5VL image processor, instead of adding a new op for Smart Resize, which the model uses, we added smart resize parameters to finetune the pre-existing Resize op.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great suggestion — done in 2f42800. Consolidated into a single Gemma4Audio op that selects the front-end via a type attribute:

  • type="log_mel" (default) → 128-dim USM log-mel spectrogram (E2B/E4B)
  • type="raw_frames" → raw 640-sample waveform frames (12B unified)

Both branches now share the same (features: float, mask: bool) output signature (the raw-frames path emits an all-true frame mask, which also matches HF's Gemma4UnifiedAudioFeatureExtractor returning input_features + input_features_mask). Gemma4Audio internally delegates to the unchanged Gemma4LogMel DSP and the raw-frames impl, so there's no duplicated logic.

One back-compat note: Gemma4LogMel already exists on main and is referenced by shipped gemma-4 configs, so I kept it registered as a thin alias (identical code path) rather than removing it — existing/deployed configs keep loading. The repo's own gemma-4 and gemma-4-unified test configs now both use Gemma4Audio. All 9 Gemma4 tests pass (log-mel via type=log_mel, raw frames via type=raw_frames incl. mask assertions).

I also propagated the op rename to the companion onnxruntime-genai (microsoft/onnxruntime-genai#2286) and mobius PRs. Let me know if you'd prefer I drop the Gemma4LogMel alias entirely (would be a breaking change for existing gemma-4 configs) or rename the attribute (type → e.g. mode).


SpeechFeatureExtractor::SpeechFeatureExtractor() : OrtxObjectImpl(extObjectKind_t::kOrtxKindFeatureExtractor) {}

Expand Down
23 changes: 23 additions & 0 deletions test/data/models/gemma-4-unified/audio_feature_extraction.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
{
"feature_extraction": {
"sequence": [
{
"operation": {
"name": "audio_decoder",
"type": "AudioDecoder"
}
},
{
"operation": {
"name": "gemma4_unified_audio_frames",
"type": "Gemma4UnifiedAudioFrames",
"attrs": {
"audio_samples_per_token": 640,
"sampling_rate": 16000,
"padding_value": 0.0
}
}
}
]
}
}
27 changes: 27 additions & 0 deletions test/data/models/gemma-4-unified/image_processor.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
{
"processor": {
"name": "gemma_4_unified_image_processing",
"transforms": [
{
"operation": {
"name": "decode_image",
"type": "DecodeImage",
"attrs": {
"color_space": "RGB"
}
}
},
{
"operation": {
"name": "gemma4_image_transform",
"type": "Gemma4ImageTransform",
"attrs": {
"patch_size": 48,
"max_soft_tokens": 280,
"pooling_kernel_size": 1
}
}
}
]
}
}
38 changes: 37 additions & 1 deletion test/pp_api_test/test_feature_extraction.cc
Original file line number Diff line number Diff line change
Expand Up @@ -446,4 +446,40 @@ TEST(ExtractorTest, TestGemma4AudioFeatureExtractionMultiFile) {
ASSERT_EQ(err, kOrtxOK);
ASSERT_EQ(mask_dims, 2ULL);
ASSERT_EQ(mask_shape[0], 2);
}
}

TEST(ExtractorTest, TestGemma4UnifiedAudioFrames) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

could we add a multi-file raw_frames test (e.g. two clips with different lengths) for Gemma4Audio with type=raw_frames? this would lock in batch stacking + mask behavior for the unified path, similar to the existing multi-file coverage on the log-mel side.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added TestGemma4UnifiedAudioFramesMultiFile in 920f54b: two clips of different lengths (jfk.flac + 1272-141231-0002.wav). It asserts batch stacking to (2, max_tokens, 640), and that each row's mask is a contiguous true-prefix / false-suffix — i.e. the shorter clip's padded tail is marked invalid while its real frames stay valid. This locks in the batch + mask behavior for the raw-frames path, mirroring the log-mel multi-file coverage.

// gemma-4-12B "unified" (encoder-free) audio: raw 16 kHz waveform chunked
// into fixed 640-sample frames. Pipeline: AudioDecoder -> Gemma4UnifiedAudioFrames
const char* audio_path[] = {"data/jfk.flac"};
OrtxObjectPtr<OrtxRawAudios> raw_audios;
extError_t err = OrtxLoadAudios(raw_audios.ToBeAssigned(), audio_path, 1);
ASSERT_EQ(err, kOrtxOK);

OrtxObjectPtr<OrtxFeatureExtractor> feature_extractor(
OrtxCreateSpeechFeatureExtractor, "data/models/gemma-4-unified/audio_feature_extraction.json");
OrtxObjectPtr<OrtxTensorResult> result;
err = OrtxFeatureExtraction(feature_extractor.get(), raw_audios.get(), result.ToBeAssigned());
ASSERT_EQ(err, kOrtxOK);

// Output 0: raw waveform frames — float (batch, num_tokens, 640)
OrtxObjectPtr<OrtxTensor> tensor;
err = OrtxTensorResultGetAt(result.get(), 0, tensor.ToBeAssigned());
ASSERT_EQ(err, kOrtxOK);

const float* data{};
const int64_t* shape{};
size_t num_dims;
err = OrtxGetTensorData(tensor.get(), reinterpret_cast<const void**>(&data), &shape, &num_dims);
ASSERT_EQ(err, kOrtxOK);
ASSERT_EQ(num_dims, 3ULL); // (batch, num_tokens, samples_per_token)
ASSERT_EQ(shape[0], 1); // single audio
ASSERT_EQ(shape[2], 640); // 640 raw samples per token
EXPECT_GT(shape[1], 0); // at least one frame

// All values finite and within the normalized PCM range.
for (int64_t i = 0; i < std::min<int64_t>(shape[1] * 640, 5000); ++i) {
ASSERT_TRUE(std::isfinite(data[i])) << "frame value at index " << i << " is not finite";
ASSERT_LE(std::abs(data[i]), 4.0f) << "frame value at index " << i << " out of range";
}
}
114 changes: 114 additions & 0 deletions test/pp_api_test/test_processor.cc
Original file line number Diff line number Diff line change
Expand Up @@ -407,6 +407,120 @@ TEST(ProcessorTest, TestGemma4ImageProcessing) {
EXPECT_EQ(nst_peek[0], 260) << "num_soft_tokens should be 260 for australia.jpg (HF reference)";
}

TEST(ProcessorTest, TestGemma4UnifiedImageProcessing) {
// gemma-4-12B "unified" (encoder-free) vision preprocessing.
//
// The unified model consumes 48px MERGED patches (patch_dim = 48*48*3 = 6912)
// directly, with no SigLIP encoder to pool 3x3 teacher patches. HuggingFace
// produces these via (16px patchify -> 3x3 patches_merge). That is provably
// identical to a direct 48px patchify, so the unified contract is generated by
// reusing Gemma4ImageTransform with patch_size=48, pooling_kernel_size=1.
//
// This test verifies (a) the 6912-dim contract and (b) that the reused op's
// top-left 48px patch matches the 16px teacher patch from the standard config.
const char* image_path[] = {"data/processor/australia.jpg"};

OrtxObjectPtr<OrtxRawImages> raw_images{};
extError_t err = OrtxLoadImages(raw_images.ToBeAssigned(), image_path, 1, nullptr);
ASSERT_EQ(err, kOrtxOK);

// --- unified config: 48px merged patches ---
OrtxObjectPtr<OrtxProcessor> processor;
err = OrtxCreateProcessor(processor.ToBeAssigned(), "data/models/gemma-4-unified/image_processor.json");
if (err != kOrtxOK) {
std::cout << "Error: " << OrtxGetLastErrorMessage() << std::endl;
}
ASSERT_EQ(err, kOrtxOK);

OrtxObjectPtr<OrtxTensorResult> result;
err = OrtxImagePreProcess(processor.get(), raw_images.get(), result.ToBeAssigned());
ASSERT_EQ(err, kOrtxOK);

OrtxObjectPtr<OrtxTensor> tensor;
err = OrtxTensorResultGetAt(result.get(), 0, tensor.ToBeAssigned());
ASSERT_EQ(err, kOrtxOK);

const float* pv_data{};
const int64_t* shape{};
size_t num_dims;
err = OrtxGetTensorData(tensor.get(), reinterpret_cast<const void**>(&pv_data), &shape, &num_dims);
ASSERT_EQ(err, kOrtxOK);
ASSERT_EQ(num_dims, 3ULL);
ASSERT_EQ(shape[0], 1);
constexpr int64_t kMaxSoftTokens = 280;
constexpr int64_t kMergedPatchDim = 48 * 48 * 3; // 6912
ASSERT_EQ(shape[1], kMaxSoftTokens);
ASSERT_EQ(shape[2], kMergedPatchDim);

// position_ids — merged 48-grid coordinates.
err = OrtxTensorResultGetAt(result.get(), 1, tensor.ToBeAssigned());
ASSERT_EQ(err, kOrtxOK);
const int64_t* pos_data{};
err = OrtxGetTensorData(tensor.get(), reinterpret_cast<const void**>(&pos_data), &shape, &num_dims);
ASSERT_EQ(err, kOrtxOK);
EXPECT_EQ(pos_data[0], 0); // patch 0 x
EXPECT_EQ(pos_data[1], 0); // patch 0 y
Comment on lines +459 to +467

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in ff3d01b. Added rank/shape asserts on the position_ids tensor (rank 3, shape (1, max_soft_tokens, 2)) before any pos_data indexing.


// num_soft_tokens: merged count = teacher-grid / 9. For australia.jpg the
// teacher grid is 60x39 -> merged 20x13 = 260 (same value as the standard
// config, which reports it before pooling).
OrtxObjectPtr<OrtxTensor> nst_tensor;
err = OrtxTensorResultGetAt(result.get(), 2, nst_tensor.ToBeAssigned());
ASSERT_EQ(err, kOrtxOK);
const int64_t* nst{};
const int64_t* nst_shape{};
size_t nst_dims{};
err = OrtxGetTensorData(nst_tensor.get(), reinterpret_cast<const void**>(&nst), &nst_shape, &nst_dims);
ASSERT_EQ(err, kOrtxOK);
EXPECT_EQ(nst[0], 260) << "merged soft-token count for australia.jpg (HF reference)";
const int64_t num_merged = nst[0];
// Last real merged patch position: (20-1, 13-1) = (19, 12).
Comment on lines +478 to +487

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in ff3d01b. The test now asserts the num_soft_tokens tensor rank/shape and that the value is within [1, max_soft_tokens] before it's used to index pos_data.

EXPECT_EQ(pos_data[(num_merged - 1) * 2], 19);
EXPECT_EQ(pos_data[(num_merged - 1) * 2 + 1], 12);
// Padding beyond real patches is (-1, -1).
for (int64_t i = num_merged; i < kMaxSoftTokens; ++i) {
EXPECT_EQ(pos_data[i * 2], -1);
EXPECT_EQ(pos_data[i * 2 + 1], -1);
}

// Copy merged patch 0 before running the second (teacher) config, which
// invalidates pv_data.
std::vector<float> merged_patch0(pv_data, pv_data + kMergedPatchDim);
Comment thread
justinchuby marked this conversation as resolved.
Outdated

// --- standard config: 16px teacher patches ---
OrtxObjectPtr<OrtxProcessor> teacher_proc;
err = OrtxCreateProcessor(teacher_proc.ToBeAssigned(), "data/models/gemma-4/image_processor.json");
ASSERT_EQ(err, kOrtxOK);
OrtxObjectPtr<OrtxTensorResult> teacher_result;
err = OrtxImagePreProcess(teacher_proc.get(), raw_images.get(), teacher_result.ToBeAssigned());
ASSERT_EQ(err, kOrtxOK);
OrtxObjectPtr<OrtxTensor> teacher_tensor;
err = OrtxTensorResultGetAt(teacher_result.get(), 0, teacher_tensor.ToBeAssigned());
ASSERT_EQ(err, kOrtxOK);
const float* teacher_pv{};
const int64_t* teacher_shape{};
size_t teacher_dims{};
err = OrtxGetTensorData(teacher_tensor.get(), reinterpret_cast<const void**>(&teacher_pv), &teacher_shape,
&teacher_dims);
ASSERT_EQ(err, kOrtxOK);

// Merged patch 0 is the top-left 48x48 image block, HWC. Its top-left 16x16
// sub-block must equal teacher patch 0 (also the top-left 16x16 block, HWC).
// merged flat index: ((r*48 + col)*3 + c) for r,col in [0,16)
// teacher flat index: ((r*16 + col)*3 + c)
constexpr int64_t kTeacherPatchDim = 16 * 16 * 3; // 768
for (int64_t r = 0; r < 16; ++r) {
Comment thread
justinchuby marked this conversation as resolved.
for (int64_t col = 0; col < 16; ++col) {
for (int64_t c = 0; c < 3; ++c) {
float m = merged_patch0[(r * 48 + col) * 3 + c];
float t = teacher_pv[(r * 16 + col) * 3 + c];
ASSERT_NEAR(m, t, 1e-6f) << "merged vs teacher mismatch at r=" << r << " col=" << col << " c=" << c;
}
}
}
(void)kTeacherPatchDim;
}

TEST(ProcessorTest, TestGemma4ImageProcessingMultiImage) {
// Verify batched processing works with multiple images of different sizes.
const char* image_paths[] = {"data/processor/standard_s.jpg", "data/processor/australia.jpg"};
Expand Down
Loading