Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
3 changes: 2 additions & 1 deletion packages/sdk/tests-qvac/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,10 @@
"prepare:sdk": "cd .. && (bun i && bun run build); cd tests-qvac",
"build": "npm run clean && tsc",
"install:build": "npm install --install-links && npm run build",
"install:build:full": "npm run prepare:sdk && npm run install:build",
"install:build:full": "npm run prepare:sdk && npm run clean:sdk-snapshot && npm run install:build",
"typecheck": "tsc --noEmit",
"clean": "rm -rf dist",
"clean:sdk-snapshot": "rm -rf node_modules/@qvac/sdk build/consumers/ios/node_modules/@qvac/sdk build/consumers/android/node_modules/@qvac/sdk",
"consumer:desktop:run": "qvac-test run:consumer:desktop"
},
"dependencies": {
Expand Down
8 changes: 0 additions & 8 deletions packages/sdk/tests-qvac/tests/desktop/consumer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -222,7 +222,6 @@ const referenceAudioPath = path.resolve(process.cwd(), "assets/audio/transcripti
resources.define("tts-chatterbox", {
constant: TTS_TOKENIZER_EN_CHATTERBOX,
type: "tts",
skipPreDownload: true,
preLoadUnload: true,
config: {
ttsEngine: "chatterbox",
Expand Down Expand Up @@ -250,7 +249,6 @@ const ttsSupertonicBaseConfig = {
resources.define("tts-supertonic", {
constant: TTS_SUPERTONIC2_OFFICIAL_TEXT_ENCODER_SUPERTONE_FP32,
type: "onnx-tts",
skipPreDownload: true,
preLoadUnload: true,
config: {
...ttsSupertonicBaseConfig,
Expand All @@ -261,7 +259,6 @@ resources.define("tts-supertonic", {
resources.define("tts-supertonic-multilingual", {
constant: TTS_SUPERTONIC2_OFFICIAL_TEXT_ENCODER_SUPERTONE_FP32,
type: "onnx-tts",
skipPreDownload: true,
preLoadUnload: true,
config: {
...ttsSupertonicBaseConfig,
Expand All @@ -274,7 +271,6 @@ resources.define("tts-supertonic-multilingual", {
resources.define("parakeet-tdt", {
constant: PARAKEET_TDT_ENCODER_INT8,
type: "parakeet",
skipPreDownload: true,
preLoadUnload: true,
config: {
parakeetEncoderSrc: PARAKEET_TDT_ENCODER_INT8,
Expand All @@ -288,7 +284,6 @@ resources.define("parakeet-tdt", {
resources.define("parakeet-ctc", {
constant: PARAKEET_CTC_FP32,
type: "parakeet",
skipPreDownload: true,
preLoadUnload: true,
config: {
modelType: "ctc",
Expand All @@ -301,7 +296,6 @@ resources.define("parakeet-ctc", {
resources.define("parakeet-sortformer", {
constant: PARAKEET_SORTFORMER_FP32,
type: "parakeet",
skipPreDownload: true,
preLoadUnload: true,
config: {
modelType: "sortformer",
Expand All @@ -312,7 +306,6 @@ resources.define("parakeet-sortformer", {
resources.define("vision", {
constant: SMOLVLM2_500M_MULTIMODAL_Q8_0,
type: "llm",
skipPreDownload: true,
preLoadUnload: true,
config: {
ctx_size: 1024,
Expand All @@ -323,7 +316,6 @@ resources.define("vision", {
resources.define("diffusion", {
constant: FLUX_2_KLEIN_4B_Q4_0,
type: "diffusion",
skipPreDownload: true,
preLoadUnload: true,
config: {
device: "gpu",
Expand Down
23 changes: 23 additions & 0 deletions packages/sdk/tests-qvac/tests/mobile/asset-uri.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
/**
* Resolve a bundled RN asset module to a real filesystem path.
*
* On Android release builds expo-asset pre-sets `downloaded=true` and
* `localUri` to a drawable resource name (e.g. `assets_images_cat`),
* bypassing the native downloadAsync() that copies the resource to a
* cache file. Resetting `downloaded=false` forces the native module to
* run and produce a real `file://` URI — which we strip + percent-decode
* because SDK native addons (whisper, FFmpeg, ONNX runtime) expect POSIX
* paths, not URIs.
*/
export async function resolveBundledAssetUri(assetModule: number): Promise<string> {
// @ts-ignore - expo-asset is a peer dependency available in mobile context
const { Asset } = await import("expo-asset");
const asset = Asset.fromModule(assetModule);
asset.downloaded = false;
await asset.downloadAsync();
const rawUri: string | undefined = asset.localUri ?? asset.uri;
if (!rawUri) {
throw new Error(`Failed to resolve asset: ${asset.name ?? "unknown"}`);
}
return decodeURIComponent(rawUri.replace(/^file:\/\//, ""));
}
40 changes: 31 additions & 9 deletions packages/sdk/tests-qvac/tests/mobile/consumer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import {
SD_V2_1_1B_Q8_0,
} from "@qvac/sdk";
import { ResourceManager } from "../shared/resource-manager.js";
import { resolveBundledAssetUri } from "./asset-uri.js";
import { ModelLoadingExecutor } from "../shared/executors/model-loading-executor.js";
import { CompletionExecutor } from "../shared/executors/completion-executor.js";
import { EmbeddingExecutor } from "../shared/executors/embedding-executor.js";
Expand Down Expand Up @@ -214,20 +215,37 @@ resources.define("afriquegemma", {
},
});

/** Look up a bundled audio file by name and resolve it to a POSIX path. */
async function resolveBundledAudioUri(filename: string): Promise<string | undefined> {
// @ts-ignore - assets.ts generated at consumer build time (consumer root, 3 levels up from dist/tests/mobile/)
const assets = await import("../../../assets");
const assetModule = assets.audio?.[filename];
if (!assetModule) {
console.warn(`[tts-chatterbox] reference audio not in registry: ${filename}`);
return undefined;
}
try {
return await resolveBundledAssetUri(assetModule);
} catch (err) {
console.warn(`[tts-chatterbox] failed to resolve ${filename}:`, err);
return undefined;
}
}

resources.define("tts-chatterbox", {
constant: TTS_TOKENIZER_EN_CHATTERBOX,
type: "tts",
skipPreDownload: true,
config: {
preLoadUnload: true,
config: async () => ({
ttsEngine: "chatterbox",
language: "en",
ttsTokenizerSrc: TTS_TOKENIZER_EN_CHATTERBOX,
ttsSpeechEncoderSrc: TTS_SPEECH_ENCODER_EN_CHATTERBOX_FP32,
ttsEmbedTokensSrc: TTS_EMBED_TOKENS_EN_CHATTERBOX_FP32,
ttsConditionalDecoderSrc: TTS_CONDITIONAL_DECODER_EN_CHATTERBOX_FP32,
ttsLanguageModelSrc: TTS_LANGUAGE_MODEL_EN_CHATTERBOX_FP32,
},
referenceAudioSrc: await resolveBundledAudioUri("transcription-short-wav.wav"),
}),
});

const ttsSupertonicBaseConfig = {
Expand All @@ -244,7 +262,7 @@ const ttsSupertonicBaseConfig = {
resources.define("tts-supertonic", {
constant: TTS_SUPERTONIC2_OFFICIAL_TEXT_ENCODER_SUPERTONE_FP32,
type: "onnx-tts",
skipPreDownload: true,
preLoadUnload: true,
config: {
...ttsSupertonicBaseConfig,
language: "en",
Expand All @@ -254,7 +272,7 @@ resources.define("tts-supertonic", {
resources.define("tts-supertonic-multilingual", {
constant: TTS_SUPERTONIC2_OFFICIAL_TEXT_ENCODER_SUPERTONE_FP32,
type: "onnx-tts",
skipPreDownload: true,
preLoadUnload: true,
Comment thread
Victor-Rodzko marked this conversation as resolved.
config: {
...ttsSupertonicBaseConfig,
language: "es",
Expand All @@ -266,7 +284,7 @@ resources.define("tts-supertonic-multilingual", {
resources.define("parakeet-tdt", {
constant: PARAKEET_TDT_ENCODER_INT8,
type: "parakeet",
skipPreDownload: true,
preLoadUnload: true,
config: {
parakeetEncoderSrc: PARAKEET_TDT_ENCODER_INT8,
parakeetDecoderSrc: PARAKEET_TDT_DECODER_INT8,
Expand All @@ -279,7 +297,7 @@ resources.define("parakeet-tdt", {
resources.define("parakeet-ctc", {
constant: PARAKEET_CTC_FP32,
type: "parakeet",
skipPreDownload: true,
preLoadUnload: true,
config: {
modelType: "ctc",
parakeetCtcModelSrc: PARAKEET_CTC_FP32,
Expand All @@ -291,7 +309,7 @@ resources.define("parakeet-ctc", {
resources.define("parakeet-sortformer", {
constant: PARAKEET_SORTFORMER_FP32,
type: "parakeet",
skipPreDownload: true,
preLoadUnload: true,
config: {
modelType: "sortformer",
parakeetSortformerSrc: PARAKEET_SORTFORMER_FP32,
Expand All @@ -301,7 +319,7 @@ resources.define("parakeet-sortformer", {
resources.define("vision", {
constant: SMOLVLM2_500M_MULTIMODAL_Q8_0,
type: "llm",
skipPreDownload: true,
preLoadUnload: true,
config: {
ctx_size: 1024,
projectionModelSrc: MMPROJ_SMOLVLM2_500M_MULTIMODAL_Q8_0,
Expand Down Expand Up @@ -369,6 +387,10 @@ export const executor = createExecutor({
new SkipExecutor(/^translation-afriquegemma-/, "AfriqueGemma 4B (~2.7 GB) exceeds iOS memory budget"),
// TODO(QVAC-18460): re-enable once iOS transcribe() crash is fixed.
new SkipExecutor(/^transcription-/, "TODO(QVAC-18460): transcription disabled on iOS — transcribe() hard-crashes consumer after FFmpegDecoder unload"),
skipTests([
"config-reload-then-transcribe",
"error-transcription-failed",
], "TODO(QVAC-18460): transcribe() hard-crashes consumer on iOS"),
] : []),

// Real executors
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { AssetExecutor, type TestDefinitions } from "@tetherto/qvac-test-suite/mobile";
import type { ResourceManager } from "../../shared/resource-manager.js";
import { modelSetup, modelTeardown } from "../../shared/resource-lifecycle.js";
import { resolveBundledAssetUri } from "../asset-uri.js";

/**
* Combines AssetExecutor's resolveAsset() with the shared model
Expand All @@ -13,28 +14,8 @@ export abstract class ModelAssetExecutor<
super();
}

/**
* On Android release builds, images are bundled as drawable resources.
* expo-asset pre-sets downloaded=true and localUri to the drawable resource
* name (e.g. "assets_images_cat"), bypassing the native downloadAsync()
* that would copy the resource to a real cache file. Resetting downloaded=false
* forces the native module to run, which uses Android's ResourceManager to
* copy the drawable to the ExponentAsset cache and returns a real file:// URI.
*/
protected override async resolveAsset(assetModule: number): Promise<string> {
// @ts-ignore - expo-asset is a peer dependency available in mobile context
const { Asset } = await import("expo-asset");
const asset = Asset.fromModule(assetModule);
asset.downloaded = false;
await asset.downloadAsync();
let uri: string = asset.localUri || asset.uri;
if (!uri) {
throw new Error(`Failed to resolve asset: ${asset.name ?? "unknown"}`);
}
if (uri.startsWith("file://")) {
uri = uri.substring(7);
}
return decodeURIComponent(uri);
return await resolveBundledAssetUri(assetModule);
}

async setup(testId: string, context: unknown) {
Expand Down
41 changes: 0 additions & 41 deletions packages/sdk/tests-qvac/tests/mobile/executors/tts-executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,51 +30,10 @@ export class MobileTtsExecutor extends ModelAssetExecutor<typeof ttsTests> {
) as never;
protected defaultHandler = undefined;

private audioAssets: Record<string, number> | null = null;
private referenceAudioPatched = false;

constructor(resources: ResourceManager) {
super(resources);
}

async setup(testId: string, context: unknown) {
if (!this.referenceAudioPatched) {
await this.patchChatterboxReferenceAudio();
this.referenceAudioPatched = true;
}
await super.setup(testId, context);
}

private async loadAudioAssets() {
if (!this.audioAssets) {
// @ts-ignore - assets.ts is generated at consumer build time
const assets = await import("../../../../assets");
this.audioAssets = assets.audio;
}
return this.audioAssets!;
}

/**
* Resolve the reference audio asset URI and patch the tts-chatterbox
* resource definition config. Must run before the first ensureLoaded()
* call so loadModel() receives the referenceAudioSrc.
*/
private async patchChatterboxReferenceAudio() {
try {
const audio = await this.loadAudioAssets();
const assetModule = audio["transcription-short-wav.wav"];
if (!assetModule) return;

const audioUri = await this.resolveAsset(assetModule);
const def = (this.resources as unknown as { definitions: Map<string, { config?: Record<string, unknown> }> }).definitions.get("tts-chatterbox");
if (def?.config) {
def.config.referenceAudioSrc = audioUri;
}
} catch (e) {
console.warn("Failed to resolve chatterbox reference audio:", e);
}
}

private makeNonStreaming(dep: string, isEmptyTest: boolean) {
return async (params: TtsParams, expectation: Expectation): Promise<TestResult> => {
const modelId = await this.resources.ensureLoaded(dep);
Expand Down
20 changes: 17 additions & 3 deletions packages/sdk/tests-qvac/tests/shared/resource-manager.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
import { loadModel, downloadAsset, unloadModel, cancel } from "@qvac/sdk";
import type { ModelConstant } from "@qvac/sdk";

type ModelConfig = Record<string, unknown>;
type ModelConfigResolver = () => Promise<ModelConfig>;

interface ModelDefinition {
constant: ModelConstant;
type: string;
config?: Record<string, unknown>;
/** Static config or async resolver (cached per-dep) for runtime-only fields like RN asset URIs. */
config?: ModelConfig | ModelConfigResolver;
skipPreDownload?: boolean;
preLoadUnload?: true;
}
Expand Down Expand Up @@ -37,6 +41,7 @@ export interface ResourceManagerOptions {

export class ResourceManager {
private definitions = new Map<string, ModelDefinition>();
private resolvedConfigs = new Map<string, ModelConfig>();
private models = new Map<string, TrackedModel>();
private testCount = 0;
private downloaded = false;
Expand All @@ -46,6 +51,15 @@ export class ResourceManager {
this.unloadSettleMs = options.unloadSettleMs ?? 0;
}

private async resolveConfig(dep: string, def: ModelDefinition): Promise<ModelConfig | undefined> {
if (typeof def.config !== "function") return def.config;
const cached = this.resolvedConfigs.get(dep);
if (cached) return cached;
const resolved = await def.config();
this.resolvedConfigs.set(dep, resolved);
return resolved;
}

define(dep: string, definition: ModelDefinition) {
this.definitions.set(dep, definition);
}
Expand Down Expand Up @@ -106,7 +120,7 @@ export class ResourceManager {
const modelId = await loadModel({
modelSrc: def.constant as never,
modelType: def.type,
modelConfig: def.config,
modelConfig: await this.resolveConfig(dep, def),
});
log?.(`✅ pre-loaded ${dep}: ${def.constant.name} - unloading...`);
await unloadModel({ modelId });
Expand Down Expand Up @@ -140,7 +154,7 @@ export class ResourceManager {
const modelId = await loadModel({
modelSrc: def.constant as never,
modelType: def.type as "llm" | "whisper" | "embeddings",
modelConfig: def.config,
modelConfig: await this.resolveConfig(dep, def),
});

this.models.set(dep, {
Expand Down
Loading