From f9fba5a56b97854db4307beec8ec04519e49c2fa Mon Sep 17 00:00:00 2001 From: sxwxs Date: Thu, 6 Aug 2026 21:32:11 +0800 Subject: [PATCH 1/2] feat: add Qwen3-ASR audio preprocessing --- src/common/audio/qwen3_asr_preprocessor.cpp | 125 ++++++++++++++++++ src/include/audio/qwen3_asr_preprocessor.hpp | 52 ++++++++ .../qwen3_asr_preprocessor/CMakeLists.txt | 29 ++++ src/test/qwen3_asr_preprocessor/README.md | 19 +++ src/test/qwen3_asr_preprocessor/reference.py | 76 +++++++++++ src/test/qwen3_asr_preprocessor/test.cpp | 87 ++++++++++++ 6 files changed, 388 insertions(+) create mode 100644 src/common/audio/qwen3_asr_preprocessor.cpp create mode 100644 src/include/audio/qwen3_asr_preprocessor.hpp create mode 100644 src/test/qwen3_asr_preprocessor/CMakeLists.txt create mode 100644 src/test/qwen3_asr_preprocessor/README.md create mode 100644 src/test/qwen3_asr_preprocessor/reference.py create mode 100644 src/test/qwen3_asr_preprocessor/test.cpp diff --git a/src/common/audio/qwen3_asr_preprocessor.cpp b/src/common/audio/qwen3_asr_preprocessor.cpp new file mode 100644 index 00000000..e07dd6c7 --- /dev/null +++ b/src/common/audio/qwen3_asr_preprocessor.cpp @@ -0,0 +1,125 @@ +/// \file qwen3_asr_preprocessor.cpp +/// \brief Qwen3-ASR waveform-to-log-mel preprocessing. + +#include "audio/qwen3_asr_preprocessor.hpp" +#include "audio_process_utils/audioproc.hpp" + +#include +#include +#include + +Qwen3ASRPreprocessor::Qwen3ASRPreprocessor() : Qwen3ASRPreprocessor(Config{}) {} + +Qwen3ASRPreprocessor::Qwen3ASRPreprocessor(Config config) : config_(config) { + if (config_.sampling_rate <= 0 || config_.n_fft <= 0 || config_.hop_length <= 0 || + config_.num_mel_bins <= 0 || config_.n_window < 0 || config_.min_length < 0) { + throw std::invalid_argument("invalid Qwen3-ASR preprocessor configuration"); + } +} + +int Qwen3ASRPreprocessor::feature_output_length(int mel_frames) { + if (mel_frames <= 0) return 0; + + // Exact integer equivalent of Transformers' three stride-2 CNN length + // transforms. A complete 100-frame chunk produces 13 output tokens. + const int remainder = mel_frames % 100; + const int remainder_output = (remainder + 7) / 8; + return remainder_output + (mel_frames / 100) * 13; +} + +qwen3_asr_features_t Qwen3ASRPreprocessor::extract(const float* samples, std::size_t num_samples) const { + if (num_samples > 0 && samples == nullptr) { + throw std::invalid_argument("samples must not be null when num_samples is non-zero"); + } + + const std::size_t padded_samples = std::max(num_samples, config_.min_length); + std::vector waveform(padded_samples, 0.0f); + if (num_samples > 0) { + std::copy(samples, samples + num_samples, waveform.begin()); + } + + const int num_frequency_bins = config_.n_fft / 2 + 1; + const int allocated_frames = audioproc::stft_num_frames( + static_cast(waveform.size()), config_.n_fft, config_.hop_length, /*center=*/true); + if (allocated_frames <= 1) { + return {}; + } + + const std::vector window = audioproc::window_function_optimized( + config_.n_fft, "hann", /*periodic=*/true); + const std::vector mel_filters = audioproc::mel_filter_bank_optimized( + num_frequency_bins, + config_.num_mel_bins, + 0.0f, + static_cast(config_.sampling_rate) / 2.0f, + config_.sampling_rate, + /*apply_slaney_norm=*/true, + /*slaney_mel_scale=*/true); + + std::vector power_spec( + static_cast(allocated_frames) * num_frequency_bins); + const int stft_frames = audioproc::stft_power_optimized( + waveform.data(), + static_cast(waveform.size()), + window.data(), + config_.n_fft, + config_.hop_length, + /*center=*/true, + audioproc::StftPadMode::reflect, + power_spec.data()); + + // Qwen3ASRFeatureExtractor uses stft[..., :-1]. + const int valid_frames = stft_frames - 1; + if (valid_frames <= 0) { + return {}; + } + + // audioproc emits [frames, mel_bins]. + std::vector frame_major( + static_cast(valid_frames) * config_.num_mel_bins); + audioproc::mel_spectrogram_optimized( + power_spec.data(), + mel_filters.data(), + frame_major.data(), + valid_frames, + num_frequency_bins, + config_.num_mel_bins); + + const int feature_count = valid_frames * config_.num_mel_bins; + std::vector log_mel(feature_count); + // Use the scalar log10 path here. The AVX512 helper intentionally uses a + // low-order logarithm approximation which is fast but introduces errors up + // to ~2e-2 after Qwen's normalization, large enough to affect ASR parity. + audioproc::log_mel_floor( + frame_major.data(), log_mel.data(), feature_count, 1e-10f); + + const float max_value = audioproc::reduce_max(log_mel.data(), feature_count); + audioproc::clamp_below_max(log_mel.data(), feature_count, max_value, 8.0f); + audioproc::affine_scale(log_mel.data(), feature_count, 4.0f, 4.0f); + + int padded_frames = valid_frames; + const int frame_multiple = config_.n_window * 2; + if (frame_multiple > 1) { + const int remainder = padded_frames % frame_multiple; + if (remainder != 0) padded_frames += frame_multiple - remainder; + } + + qwen3_asr_features_t result; + result.num_mel_bins = config_.num_mel_bins; + result.num_frames = padded_frames; + result.valid_frames = valid_frames; + result.input_features.assign( + static_cast(config_.num_mel_bins) * padded_frames, 0.0f); + result.attention_mask.assign(padded_frames, 0); + std::fill(result.attention_mask.begin(), result.attention_mask.begin() + valid_frames, 1); + + // Transformers layout is [mel_bins, frames]. + for (int frame = 0; frame < valid_frames; ++frame) { + for (int mel = 0; mel < config_.num_mel_bins; ++mel) { + result.input_features[static_cast(mel) * padded_frames + frame] = + log_mel[static_cast(frame) * config_.num_mel_bins + mel]; + } + } + + return result; +} diff --git a/src/include/audio/qwen3_asr_preprocessor.hpp b/src/include/audio/qwen3_asr_preprocessor.hpp new file mode 100644 index 00000000..bba17dd7 --- /dev/null +++ b/src/include/audio/qwen3_asr_preprocessor.hpp @@ -0,0 +1,52 @@ +/// \file qwen3_asr_preprocessor.hpp +/// \brief Qwen3-ASR waveform-to-log-mel preprocessing. +#pragma once + +#include +#include + +struct qwen3_asr_features_t { + // Feature-major layout matching Transformers input_features: + // [num_mel_bins, num_frames], contiguous in row-major order. + std::vector input_features; + std::vector attention_mask; + int num_mel_bins = 0; + int num_frames = 0; + int valid_frames = 0; +}; + +class Qwen3ASRPreprocessor { +public: + struct Config { + int sampling_rate = 16000; + int n_fft = 400; + int hop_length = 160; + int num_mel_bins = 128; + int n_window = 50; + int min_length = 8000; + }; + + Qwen3ASRPreprocessor(); + explicit Qwen3ASRPreprocessor(Config config); + + /// Convert mono float32 PCM into Qwen3-ASR log-mel features. + /// + /// This follows Hugging Face Qwen3ASRFeatureExtractor: + /// - zero-pad waveforms shorter than min_length; + /// - periodic Hann window and centered reflect-padded STFT; + /// - Slaney mel scale and normalization; + /// - log10 clamp, dynamic-range clamp, and (x + 4) / 4 scaling; + /// - right-pad the mel time axis to a multiple of 2 * n_window. + qwen3_asr_features_t extract(const float* samples, std::size_t num_samples) const; + qwen3_asr_features_t extract(const std::vector& samples) const { + return extract(samples.data(), samples.size()); + } + + /// Number of soft audio tokens produced by the three stride-2 CNN layers. + static int feature_output_length(int mel_frames); + + const Config& config() const { return config_; } + +private: + Config config_; +}; diff --git a/src/test/qwen3_asr_preprocessor/CMakeLists.txt b/src/test/qwen3_asr_preprocessor/CMakeLists.txt new file mode 100644 index 00000000..d0c94e19 --- /dev/null +++ b/src/test/qwen3_asr_preprocessor/CMakeLists.txt @@ -0,0 +1,29 @@ +cmake_minimum_required(VERSION 3.22) +project(qwen3_asr_preprocessor_test LANGUAGES CXX) + +set(CMAKE_CXX_STANDARD 20) +set(CMAKE_CXX_STANDARD_REQUIRED ON) + +find_package(OpenMP) +find_library(FFTW3F_LIBRARY NAMES fftw3f libfftw3f.so.3 REQUIRED) + +add_executable(test_qwen3_asr_preprocessor + test.cpp + ../../common/audio/qwen3_asr_preprocessor.cpp + ../../common/audio_process_utils/audioproc.cpp + ../../common/audio_process_utils/audioprocAVX512.cpp +) + +target_include_directories(test_qwen3_asr_preprocessor PRIVATE ../../include) +target_compile_definitions(test_qwen3_asr_preprocessor PRIVATE USEAVX2=1 USEAVX512=1) +target_compile_options(test_qwen3_asr_preprocessor PRIVATE + -O3 -ffast-math -mavx2 -mfma -mavx512f -mavx512dq -mavx512bw -mavx512vl +) +target_link_libraries(test_qwen3_asr_preprocessor PRIVATE ${FFTW3F_LIBRARY}) + +if(OpenMP_CXX_FOUND) + target_link_libraries(test_qwen3_asr_preprocessor PRIVATE OpenMP::OpenMP_CXX) +endif() + +enable_testing() +add_test(NAME qwen3_asr_preprocessor COMMAND test_qwen3_asr_preprocessor) diff --git a/src/test/qwen3_asr_preprocessor/README.md b/src/test/qwen3_asr_preprocessor/README.md new file mode 100644 index 00000000..fc1aa566 --- /dev/null +++ b/src/test/qwen3_asr_preprocessor/README.md @@ -0,0 +1,19 @@ +# Qwen3-ASR preprocessor parity test + +This standalone test validates the C++ waveform frontend without requiring a +Qwen3-ASR NPU kernel or model weights. + +It checks the Qwen3-ASR feature-length rules, minimum input padding, mel-axis +padding, tensor layout, and finite outputs. The Python script then compares the +C++ features with the Hugging Face reference operations. + +```bash +cmake -S . -B build -G Ninja +cmake --build build +ctest --test-dir build --output-on-failure + +./build/test_qwen3_asr_preprocessor --dump /tmp/qwen3_asr_features.bin +python3 reference.py /tmp/qwen3_asr_features.bin +``` + +The Python parity step requires `numpy`, `torch`, and `transformers`. diff --git a/src/test/qwen3_asr_preprocessor/reference.py b/src/test/qwen3_asr_preprocessor/reference.py new file mode 100644 index 00000000..966463a1 --- /dev/null +++ b/src/test/qwen3_asr_preprocessor/reference.py @@ -0,0 +1,76 @@ +#!/usr/bin/env python3 +"""Compare the C++ Qwen3-ASR frontend with the Hugging Face reference math.""" + +from __future__ import annotations + +import argparse +import struct +from pathlib import Path + +import numpy as np +import torch +from transformers.audio_utils import mel_filter_bank + + +def make_waveform(num_samples: int = 16000) -> np.ndarray: + values = np.empty(num_samples, dtype=np.float32) + state = 0x12345678 + for index in range(num_samples): + state = (state * 1664525 + 1013904223) & 0xFFFFFFFF + unit = np.float32(state >> 8) / np.float32(16777216.0) + values[index] = unit * np.float32(0.4) - np.float32(0.2) + return values + + +def reference_features(waveform: np.ndarray) -> np.ndarray: + waveform_t = torch.from_numpy(waveform).to(torch.float32) + window = torch.hann_window(400) + stft = torch.stft(waveform_t, 400, 160, window=window, return_complex=True) + magnitudes = stft[..., :-1].abs() ** 2 + + filters = mel_filter_bank( + num_frequency_bins=201, + num_mel_filters=128, + min_frequency=0.0, + max_frequency=8000.0, + sampling_rate=16000, + norm="slaney", + mel_scale="slaney", + ) + mel_spec = torch.from_numpy(filters).to(torch.float32).T @ magnitudes + log_spec = torch.clamp(mel_spec, min=1e-10).log10() + log_spec = torch.maximum(log_spec, log_spec.max() - 8.0) + return ((log_spec + 4.0) / 4.0).numpy() + + +def read_cpp_features(path: Path) -> np.ndarray: + data = path.read_bytes() + mel_bins, frames, valid_frames = struct.unpack_from(" None: + parser = argparse.ArgumentParser() + parser.add_argument("fixture", type=Path, help="file emitted by test_qwen3_asr_preprocessor --dump") + parser.add_argument("--atol", type=float, default=2e-4) + parser.add_argument("--rtol", type=float, default=2e-4) + args = parser.parse_args() + + expected = reference_features(make_waveform()) + actual = read_cpp_features(args.fixture) + if actual.shape != expected.shape: + raise SystemExit(f"shape mismatch: C++={actual.shape}, reference={expected.shape}") + + difference = np.abs(actual - expected) + print(f"shape: {actual.shape}") + print(f"max abs error: {difference.max():.8g}") + print(f"mean abs error: {difference.mean():.8g}") + np.testing.assert_allclose(actual, expected, atol=args.atol, rtol=args.rtol) + print("Qwen3-ASR C++/Transformers preprocessing parity passed") + + +if __name__ == "__main__": + main() diff --git a/src/test/qwen3_asr_preprocessor/test.cpp b/src/test/qwen3_asr_preprocessor/test.cpp new file mode 100644 index 00000000..538b7761 --- /dev/null +++ b/src/test/qwen3_asr_preprocessor/test.cpp @@ -0,0 +1,87 @@ +#include "audio/qwen3_asr_preprocessor.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +std::vector make_waveform(int num_samples) { + // Deterministic broadband signal. Using integer-generated noise avoids + // platform-specific libm differences in the C++/Python parity fixture. + std::vector samples(num_samples); + std::uint32_t state = 0x12345678U; + for (int i = 0; i < num_samples; ++i) { + state = state * 1664525U + 1013904223U; + const float unit = static_cast(state >> 8) / 16777216.0f; + samples[i] = unit * 0.4f - 0.2f; + } + return samples; +} + +bool write_features(const std::string& path, const qwen3_asr_features_t& features) { + std::ofstream output(path, std::ios::binary); + if (!output) return false; + + const std::int32_t header[] = { + features.num_mel_bins, + features.num_frames, + features.valid_frames, + }; + output.write(reinterpret_cast(header), sizeof(header)); + output.write( + reinterpret_cast(features.input_features.data()), + static_cast(features.input_features.size() * sizeof(float))); + return output.good(); +} + +bool expect(bool condition, const char* message) { + if (!condition) std::cerr << "FAIL: " << message << '\n'; + return condition; +} + +} // namespace + +int main(int argc, char** argv) { + bool ok = true; + + ok &= expect(Qwen3ASRPreprocessor::feature_output_length(0) == 0, "0 mel frames"); + ok &= expect(Qwen3ASRPreprocessor::feature_output_length(1) == 1, "1 mel frame"); + ok &= expect(Qwen3ASRPreprocessor::feature_output_length(8) == 1, "8 mel frames"); + ok &= expect(Qwen3ASRPreprocessor::feature_output_length(9) == 2, "9 mel frames"); + ok &= expect(Qwen3ASRPreprocessor::feature_output_length(99) == 13, "99 mel frames"); + ok &= expect(Qwen3ASRPreprocessor::feature_output_length(100) == 13, "100 mel frames"); + ok &= expect(Qwen3ASRPreprocessor::feature_output_length(101) == 14, "101 mel frames"); + ok &= expect(Qwen3ASRPreprocessor::feature_output_length(3000) == 390, "3000 mel frames"); + + Qwen3ASRPreprocessor preprocessor; + + const auto short_features = preprocessor.extract(make_waveform(4000)); + ok &= expect(short_features.valid_frames == 50, "min_length creates 50 valid mel frames"); + ok &= expect(short_features.num_frames == 100, "mel frames pad to 2*n_window"); + ok &= expect(short_features.num_mel_bins == 128, "128 mel bins"); + ok &= expect(short_features.input_features.size() == 128U * 100U, "feature tensor shape"); + ok &= expect(std::accumulate(short_features.attention_mask.begin(), short_features.attention_mask.end(), 0) == 50, + "attention mask preserves valid frame count"); + + const auto features = preprocessor.extract(make_waveform(16000)); + ok &= expect(features.valid_frames == 100, "one second creates 100 mel frames"); + ok &= expect(features.num_frames == 100, "one second needs no mel padding"); + ok &= expect(features.input_features.size() == 128U * 100U, "one-second feature tensor shape"); + ok &= expect(std::all_of(features.input_features.begin(), features.input_features.end(), + [](float value) { return std::isfinite(value); }), + "all features are finite"); + + if (argc == 3 && std::string(argv[1]) == "--dump") { + ok &= expect(write_features(argv[2], features), "write parity fixture"); + } + + if (!ok) return 1; + std::cout << "Qwen3-ASR preprocessing checks passed\n"; + return 0; +} From fd4ec2bd26d729a0ec6ec0031324fdb64285d71f Mon Sep 17 00:00:00 2001 From: sxwxs Date: Thu, 6 Aug 2026 21:36:18 +0800 Subject: [PATCH 2/2] feat: add Qwen3-ASR prompt and output helpers --- src/common/audio/qwen3_asr_utils.cpp | 137 ++++++++++++++++++ src/include/audio/qwen3_asr_utils.hpp | 33 +++++ .../qwen3_asr_preprocessor/CMakeLists.txt | 1 + src/test/qwen3_asr_preprocessor/test.cpp | 19 +++ 4 files changed, 190 insertions(+) create mode 100644 src/common/audio/qwen3_asr_utils.cpp create mode 100644 src/include/audio/qwen3_asr_utils.hpp diff --git a/src/common/audio/qwen3_asr_utils.cpp b/src/common/audio/qwen3_asr_utils.cpp new file mode 100644 index 00000000..a969c35f --- /dev/null +++ b/src/common/audio/qwen3_asr_utils.cpp @@ -0,0 +1,137 @@ +/// \file qwen3_asr_utils.cpp +/// \brief Prompt and output helpers for Qwen3-ASR. + +#include "audio/qwen3_asr_utils.hpp" + +#include +#include +#include +#include + +namespace { + +constexpr std::array supported_languages = { + "Chinese", "English", "Cantonese", "Arabic", "German", "French", + "Spanish", "Portuguese", "Indonesian", "Italian", "Korean", "Russian", + "Thai", "Vietnamese", "Japanese", "Turkish", "Hindi", "Malay", "Dutch", + "Swedish", "Danish", "Finnish", "Polish", "Czech", "Filipino", "Persian", + "Greek", "Romanian", "Hungarian", "Macedonian", +}; + +std::string trim(std::string_view value) { + const auto first = std::find_if_not(value.begin(), value.end(), [](unsigned char c) { + return std::isspace(c) != 0; + }); + const auto last = std::find_if_not(value.rbegin(), value.rend(), [](unsigned char c) { + return std::isspace(c) != 0; + }).base(); + if (first >= last) return {}; + return std::string(first, last); +} + +std::string ascii_lower(std::string_view value) { + std::string result(value); + std::transform(result.begin(), result.end(), result.begin(), [](unsigned char c) { + return static_cast(std::tolower(c)); + }); + return result; +} + +} // namespace + +std::string qwen3_asr_normalize_language(std::string_view language) { + std::string result = trim(language); + if (result.empty()) return result; + + std::transform(result.begin(), result.end(), result.begin(), [](unsigned char c) { + return static_cast(std::tolower(c)); + }); + result.front() = static_cast(std::toupper(static_cast(result.front()))); + return result; +} + +bool qwen3_asr_is_supported_language(std::string_view language) { + const std::string normalized = qwen3_asr_normalize_language(language); + return std::find(supported_languages.begin(), supported_languages.end(), normalized) != supported_languages.end(); +} + +std::string qwen3_asr_build_prompt( + std::string_view context, + int audio_token_count, + std::optional forced_language) { + if (audio_token_count <= 0) { + throw std::invalid_argument("audio_token_count must be positive"); + } + + std::string language; + if (forced_language.has_value()) { + language = qwen3_asr_normalize_language(*forced_language); + if (!qwen3_asr_is_supported_language(language)) { + throw std::invalid_argument("unsupported Qwen3-ASR language: " + language); + } + } + + constexpr std::string_view audio_token = "<|audio_pad|>"; + std::string prompt; + prompt.reserve(context.size() + static_cast(audio_token_count) * audio_token.size() + 160); + prompt += "<|im_start|>system\n"; + prompt += context; + prompt += "<|im_end|>\n<|im_start|>user\n<|audio_start|>"; + for (int i = 0; i < audio_token_count; ++i) prompt += audio_token; + prompt += "<|audio_end|><|im_end|>\n<|im_start|>assistant\n"; + + if (!language.empty()) { + prompt += "language "; + prompt += language; + prompt += ""; + } + return prompt; +} + +qwen3_asr_result_t qwen3_asr_parse_output( + std::string_view raw_output, + std::optional forced_language) { + qwen3_asr_result_t result; + const std::string raw = trim(raw_output); + if (raw.empty()) return result; + + if (forced_language.has_value()) { + result.language = qwen3_asr_normalize_language(*forced_language); + result.text = raw; + return result; + } + + constexpr std::string_view asr_tag = ""; + const std::size_t tag_position = raw.find(asr_tag); + if (tag_position == std::string::npos) { + result.text = raw; + return result; + } + + const std::string metadata = trim(std::string_view(raw).substr(0, tag_position)); + result.text = trim(std::string_view(raw).substr(tag_position + asr_tag.size())); + + const std::string metadata_lower = ascii_lower(metadata); + if (metadata_lower.find("language none") != std::string::npos) { + // Silent/empty audio. Preserve unexpected text but do not claim a language. + return result; + } + + std::size_t line_start = 0; + while (line_start <= metadata.size()) { + const std::size_t line_end = metadata.find('\n', line_start); + const std::string line = trim(std::string_view(metadata).substr( + line_start, + line_end == std::string::npos ? std::string::npos : line_end - line_start)); + const std::string line_lower = ascii_lower(line); + constexpr std::string_view prefix = "language "; + if (line_lower.starts_with(prefix)) { + result.language = qwen3_asr_normalize_language(std::string_view(line).substr(prefix.size())); + break; + } + if (line_end == std::string::npos) break; + line_start = line_end + 1; + } + + return result; +} diff --git a/src/include/audio/qwen3_asr_utils.hpp b/src/include/audio/qwen3_asr_utils.hpp new file mode 100644 index 00000000..7f9c1e2b --- /dev/null +++ b/src/include/audio/qwen3_asr_utils.hpp @@ -0,0 +1,33 @@ +/// \file qwen3_asr_utils.hpp +/// \brief Prompt and output helpers for Qwen3-ASR. +#pragma once + +#include +#include +#include + +struct qwen3_asr_result_t { + std::string language; + std::string text; +}; + +/// Normalize a language name to the canonical Qwen3-ASR spelling. +/// Returns an empty string for an empty input. +std::string qwen3_asr_normalize_language(std::string_view language); + +/// Whether a canonical or case-insensitive language name is supported. +bool qwen3_asr_is_supported_language(std::string_view language); + +/// Build the exact single-audio prompt used by Qwen3-ASR's chat template. +/// When forced_language is set, generation starts after +/// "language " and decoded output is text-only. +std::string qwen3_asr_build_prompt( + std::string_view context, + int audio_token_count, + std::optional forced_language = std::nullopt); + +/// Parse generated text into language and transcription. +/// If forced_language is provided, raw_output is treated as transcription-only. +qwen3_asr_result_t qwen3_asr_parse_output( + std::string_view raw_output, + std::optional forced_language = std::nullopt); diff --git a/src/test/qwen3_asr_preprocessor/CMakeLists.txt b/src/test/qwen3_asr_preprocessor/CMakeLists.txt index d0c94e19..420102ca 100644 --- a/src/test/qwen3_asr_preprocessor/CMakeLists.txt +++ b/src/test/qwen3_asr_preprocessor/CMakeLists.txt @@ -10,6 +10,7 @@ find_library(FFTW3F_LIBRARY NAMES fftw3f libfftw3f.so.3 REQUIRED) add_executable(test_qwen3_asr_preprocessor test.cpp ../../common/audio/qwen3_asr_preprocessor.cpp + ../../common/audio/qwen3_asr_utils.cpp ../../common/audio_process_utils/audioproc.cpp ../../common/audio_process_utils/audioprocAVX512.cpp ) diff --git a/src/test/qwen3_asr_preprocessor/test.cpp b/src/test/qwen3_asr_preprocessor/test.cpp index 538b7761..8e7cd9d1 100644 --- a/src/test/qwen3_asr_preprocessor/test.cpp +++ b/src/test/qwen3_asr_preprocessor/test.cpp @@ -1,4 +1,5 @@ #include "audio/qwen3_asr_preprocessor.hpp" +#include "audio/qwen3_asr_utils.hpp" #include #include @@ -59,6 +60,24 @@ int main(int argc, char** argv) { ok &= expect(Qwen3ASRPreprocessor::feature_output_length(101) == 14, "101 mel frames"); ok &= expect(Qwen3ASRPreprocessor::feature_output_length(3000) == 390, "3000 mel frames"); + ok &= expect(qwen3_asr_normalize_language(" cHINese ") == "Chinese", "normalize language"); + ok &= expect(qwen3_asr_is_supported_language("ENGLISH"), "supported language"); + ok &= expect(!qwen3_asr_is_supported_language("Klingon"), "unsupported language"); + + const std::string prompt = qwen3_asr_build_prompt("hot words", 2, "english"); + ok &= expect(prompt == + "<|im_start|>system\nhot words<|im_end|>\n" + "<|im_start|>user\n<|audio_start|><|audio_pad|><|audio_pad|><|audio_end|><|im_end|>\n" + "<|im_start|>assistant\nlanguage English", + "Qwen3-ASR chat prompt"); + + const auto parsed = qwen3_asr_parse_output("language Chinese你好,世界。 "); + ok &= expect(parsed.language == "Chinese" && parsed.text == "你好,世界。", "parse tagged output"); + const auto silent = qwen3_asr_parse_output("language None"); + ok &= expect(silent.language.empty() && silent.text.empty(), "parse silent audio"); + const auto forced = qwen3_asr_parse_output("plain transcription", "French"); + ok &= expect(forced.language == "French" && forced.text == "plain transcription", "parse forced language output"); + Qwen3ASRPreprocessor preprocessor; const auto short_features = preprocessor.extract(make_waveform(4000));