@@ -17,24 +17,47 @@ pub fn build(b: *std.Build) void {
1717 // set a preferred release mode, allowing the user to decide how to optimize.
1818 const optimize = b .standardOptimizeOption (.{});
1919
20- // Native TTS backend (aikit/qwentts.cpp). aikit's own build.zig defaults
21- // its "qwen-build-dir" option to "../vendor/qwentts.cpp/build", a
22- // `.cwd_relative` path meant to be resolved from *aikit's own* directory
23- // (i.e. running `zig build` from within aikit/). Here we're pulling
24- // aikit in as a dependency of metanoia's root build, so `zig build` is
25- // invoked from the metanoia repo root instead — the same relative
26- // default would resolve one directory too high. Override it to the
27- // correct path from *this* root: vendor/qwentts.cpp/build (no "..").
28- const aikit_dep = b .dependency ("aikit" , .{
29- .target = target ,
30- .optimize = optimize ,
31- .@"qwen-build-dir" = @as ([]const u8 , "vendor/qwentts.cpp/build" ),
32- });
33- const aikit_mod = aikit_dep .module ("aikit" );
34- // It's also possible to define more custom flags to toggle optional features
35- // of this build script using `b.option()`. All defined flags (including
36- // target and optimize options) will be listed when running `zig build --help`
37- // in this directory.
20+ // Native AI backends (aikit — TTS/LLM/STT) are opt-in, off by default.
21+ // aikit unconditionally links against qwentts.cpp (libqwen) for its TTS
22+ // backend, and on macOS also mlx-c and (if the STT capability is
23+ // reached) whisper.cpp — all real, heavy, locally-built/installed
24+ // native dependencies (see aikit/README.md) that a fresh checkout,
25+ // and CI (.github/workflows/test.yml), do NOT have. Every native
26+ // capability already defaults to "remote"/off at the *runtime config*
27+ // level (tts_backend/llm_backend default to "remote" — see
28+ // src/tts_client.zig / src/llm_client.zig), but that's meaningless if
29+ // the app can't even *compile* without those dependencies present.
30+ // This flag is the actual off switch: default false means `zig build`
31+ // / `zig build test` work with nothing beyond the README's normal
32+ // `brew install zig gtk4 pango cairo glib sqlite3`. Pass
33+ // `-Dnative-ai=true` (after building aikit's dependencies per
34+ // aikit/README.md) to link the real thing in.
35+ const native_ai = b .option (
36+ bool ,
37+ "native-ai" ,
38+ "Link aikit's native TTS/LLM/STT backends (requires qwentts.cpp/mlx-c/whisper-cpp set up locally, see aikit/README.md). Off by default so a fresh checkout builds with nothing beyond gtk4/sqlite3." ,
39+ ) orelse false ;
40+
41+ const native_ai_opts = b .addOptions ();
42+ native_ai_opts .addOption (bool , "native_ai" , native_ai );
43+ const build_options_mod = native_ai_opts .createModule ();
44+
45+ // aikit's own build.zig defaults its "qwen-build-dir" option to
46+ // "../vendor/qwentts.cpp/build", a `.cwd_relative` path meant to be
47+ // resolved from *aikit's own* directory (i.e. running `zig build` from
48+ // within aikit/). Here we're pulling aikit in as a dependency of
49+ // metanoia's root build, so `zig build` is invoked from the metanoia
50+ // repo root instead — the same relative default would resolve one
51+ // directory too high. Override it to the correct path from *this*
52+ // root: vendor/qwentts.cpp/build (no "..").
53+ const aikit_mod : ? * std.Build.Module = if (native_ai ) blk : {
54+ const aikit_dep = b .dependency ("aikit" , .{
55+ .target = target ,
56+ .optimize = optimize ,
57+ .@"qwen-build-dir" = @as ([]const u8 , "vendor/qwentts.cpp/build" ),
58+ });
59+ break :blk aikit_dep .module ("aikit" );
60+ } else null ;
3861
3962 // This creates a module, which represents a collection of source files alongside
4063 // some compilation options, such as optimization mode and linked system libraries.
@@ -43,6 +66,16 @@ pub fn build(b: *std.Build) void {
4366 // to our consumers. We must give it a name because a Zig package can expose
4467 // multiple modules and consumers will need to be able to specify which
4568 // module they want to access.
69+ // src/tts_client.zig / src/llm_client.zig (reachable from src/root.zig)
70+ // import "aikit" and "build_options" for the native backend switch —
71+ // "aikit" is only added when native_ai is true (see aikit_mod above);
72+ // both files comptime-gate their own `@import("aikit")` on
73+ // `build_options.native_ai`, so it's fine for that import to simply not
74+ // exist otherwise.
75+ var mod_imports = std .ArrayListUnmanaged (std .Build .Module .Import ).empty ;
76+ mod_imports .append (b .allocator , .{ .name = "build_options" , .module = build_options_mod }) catch @panic ("OOM" );
77+ if (aikit_mod ) | am | mod_imports .append (b .allocator , .{ .name = "aikit" , .module = am }) catch @panic ("OOM" );
78+
4679 const mod = b .addModule ("metanoia" , .{
4780 // The root source file is the "entry point" of this module. Users of
4881 // this module will only be able to access public declarations contained
@@ -54,11 +87,7 @@ pub fn build(b: *std.Build) void {
5487 // Later on we'll use this module as the root module of a test executable
5588 // which requires us to specify a target.
5689 .target = target ,
57- // src/tts_client.zig (reachable from src/root.zig) imports "aikit"
58- // for the native TTS backend.
59- .imports = &.{
60- .{ .name = "aikit" , .module = aikit_mod },
61- },
90+ .imports = mod_imports .items ,
6291 });
6392
6493 // Kit module — reusable, decoupled UI/UX component library.
@@ -92,6 +121,16 @@ pub fn build(b: *std.Build) void {
92121 //
93122 // If neither case applies to you, feel free to delete the declaration you
94123 // don't need and to put everything under a single module.
124+ // src/main.zig also imports src/tts_client.zig directly (relative
125+ // import, separate from the "metanoia" module above), so it needs its
126+ // own "aikit"/"build_options" imports too — same conditional-inclusion
127+ // reasoning as mod_imports above.
128+ var exe_imports = std .ArrayListUnmanaged (std .Build .Module .Import ).empty ;
129+ exe_imports .append (b .allocator , .{ .name = "metanoia" , .module = mod }) catch @panic ("OOM" );
130+ exe_imports .append (b .allocator , .{ .name = "kit" , .module = kit_mod }) catch @panic ("OOM" );
131+ exe_imports .append (b .allocator , .{ .name = "build_options" , .module = build_options_mod }) catch @panic ("OOM" );
132+ if (aikit_mod ) | am | exe_imports .append (b .allocator , .{ .name = "aikit" , .module = am }) catch @panic ("OOM" );
133+
95134 const exe = b .addExecutable (.{
96135 .name = "metanoia" ,
97136 .root_module = b .createModule (.{
@@ -107,19 +146,7 @@ pub fn build(b: *std.Build) void {
107146 .optimize = optimize ,
108147 // List of modules available for import in source files part of the
109148 // root module.
110- .imports = &.{
111- // Here "metanoia" is the name you will use in your source code to
112- // import this module (e.g. `@import("metanoia")`). The name is
113- // repeated because you are allowed to rename your imports, which
114- // can be extremely useful in case of collisions (which can happen
115- // importing modules from different packages).
116- .{ .name = "metanoia" , .module = mod },
117- .{ .name = "kit" , .module = kit_mod },
118- // src/main.zig also imports src/tts_client.zig directly
119- // (relative import, separate from the "metanoia" module
120- // above), so it needs its own "aikit" import too.
121- .{ .name = "aikit" , .module = aikit_mod },
122- },
149+ .imports = exe_imports .items ,
123150 }),
124151 });
125152 // GTK4 library name differs between platforms:
@@ -229,48 +256,50 @@ pub fn build(b: *std.Build) void {
229256 test_step .dependOn (& run_build_tests .step );
230257 test_step .dependOn (& run_exe_tests .step );
231258
232- // Real end-to-end native-TTS test, separate from the default `test`
233- // step: it needs the ~1.3GB GGUF weights under vendor/qwentts.cpp/models
234- // (see src/native_tts_test.zig), which normal CI and most local
235- // checkouts won't have. It self-skips (error.SkipZigTest) when the
236- // weights aren't present, but keeping it out of the default `test` step
237- // still avoids paying its (real model load + real synthesis) cost on
238- // every routine `zig build test` for contributors who do have them.
239- const native_tts_test_mod = b .createModule (.{
240- .root_source_file = b .path ("src/native_tts_test.zig" ),
241- .target = target ,
242- .optimize = optimize ,
243- .imports = &.{
244- .{ .name = "aikit" , .module = aikit_mod },
245- },
246- });
247- const native_tts_test = b .addTest (.{ .root_module = native_tts_test_mod });
248- native_tts_test .root_module .linkSystemLibrary (gtk_lib , .{});
249- native_tts_test .root_module .linkSystemLibrary ("sqlite3" , .{});
250- native_tts_test .root_module .link_libc = true ;
251- const run_native_tts_test = b .addRunArtifact (native_tts_test );
252- const native_tts_test_step = b .step ("test-native-tts" , "Run the real native-TTS end-to-end test (needs local GGUF weights)" );
253- native_tts_test_step .dependOn (& run_native_tts_test .step );
259+ // Real end-to-end native-TTS/native-LLM tests — only meaningful (and
260+ // only buildable at all, since they need "aikit") when native_ai is
261+ // true. When it's not, `zig build test-native-tts`/`test-native-llm`
262+ // simply don't exist as steps ("no step named ..." is a clear enough
263+ // signal — pass -Dnative-ai=true to get them). Separate from the
264+ // default `test` step even when native_ai is true: they need real
265+ // multi-hundred-MB model weights under vendor/ (see
266+ // src/native_tts_test.zig / src/native_llm_test.zig for exact paths),
267+ // which most checkouts — even native-ai-enabled ones — won't have set
268+ // up; they self-skip via error.SkipZigTest when absent, but keeping
269+ // them out of the default `test` step avoids paying their real
270+ // model-load-and-generate cost on every routine `zig build test` for
271+ // contributors who do have the weights.
272+ if (native_ai ) {
273+ var native_test_imports = std .ArrayListUnmanaged (std .Build .Module .Import ).empty ;
274+ native_test_imports .append (b .allocator , .{ .name = "aikit" , .module = aikit_mod .? }) catch @panic ("OOM" );
275+ native_test_imports .append (b .allocator , .{ .name = "build_options" , .module = build_options_mod }) catch @panic ("OOM" );
254276
255- // Real end-to-end native-LLM test, same reasoning/pattern as
256- // test-native-tts above: needs the ~265MB MLX checkpoint at
257- // vendor/llm/qwen2.5-0.5b-instruct-4bit/ (see src/llm_client.zig's
258- // native_model_dir), which normal CI and most local checkouts won't
259- // have — kept out of the default `test` step, self-skips via
260- // error.SkipZigTest when the weights aren't present.
261- const native_llm_test_mod = b .createModule (.{
262- .root_source_file = b .path ("src/native_llm_test.zig" ),
263- .target = target ,
264- .optimize = optimize ,
265- .imports = &.{
266- .{ .name = "aikit" , .module = aikit_mod },
267- },
268- });
269- const native_llm_test = b .addTest (.{ .root_module = native_llm_test_mod });
270- native_llm_test .root_module .link_libc = true ;
271- const run_native_llm_test = b .addRunArtifact (native_llm_test );
272- const native_llm_test_step = b .step ("test-native-llm" , "Run the real native-LLM end-to-end test (needs local MLX checkpoint)" );
273- native_llm_test_step .dependOn (& run_native_llm_test .step );
277+ const native_tts_test_mod = b .createModule (.{
278+ .root_source_file = b .path ("src/native_tts_test.zig" ),
279+ .target = target ,
280+ .optimize = optimize ,
281+ .imports = native_test_imports .items ,
282+ });
283+ const native_tts_test = b .addTest (.{ .root_module = native_tts_test_mod });
284+ native_tts_test .root_module .linkSystemLibrary (gtk_lib , .{});
285+ native_tts_test .root_module .linkSystemLibrary ("sqlite3" , .{});
286+ native_tts_test .root_module .link_libc = true ;
287+ const run_native_tts_test = b .addRunArtifact (native_tts_test );
288+ const native_tts_test_step = b .step ("test-native-tts" , "Run the real native-TTS end-to-end test (needs local GGUF weights)" );
289+ native_tts_test_step .dependOn (& run_native_tts_test .step );
290+
291+ const native_llm_test_mod = b .createModule (.{
292+ .root_source_file = b .path ("src/native_llm_test.zig" ),
293+ .target = target ,
294+ .optimize = optimize ,
295+ .imports = native_test_imports .items ,
296+ });
297+ const native_llm_test = b .addTest (.{ .root_module = native_llm_test_mod });
298+ native_llm_test .root_module .link_libc = true ;
299+ const run_native_llm_test = b .addRunArtifact (native_llm_test );
300+ const native_llm_test_step = b .step ("test-native-llm" , "Run the real native-LLM end-to-end test (needs local MLX checkpoint)" );
301+ native_llm_test_step .dependOn (& run_native_llm_test .step );
302+ }
274303
275304 // Just like flags, top level steps are also listed in the `--help` menu.
276305 //
0 commit comments