@gjsify/spiel-native 0.0.0-stage → 0.64.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,17 @@
1
+ /* What leaves libspiel-1.0.so: the spiel_* API and nothing else.
2
+ *
3
+ * ELF has ONE flat symbol namespace, so this is not cosmetic. libspeechprovider
4
+ * is linked statically into this library (meson.build § ONE library), and
5
+ * without this script its seven speech_provider_* functions are exported — where
6
+ * they would interpose on (or be interposed by) the real libspeechprovider.so
7
+ * that a speech PROVIDER in the same process links. Measured: 89 spiel_* and 0
8
+ * speech_provider_* exported with this script, 89 and 7 without it. The Spiel
9
+ * typelib names no speech_provider_* symbol, so nothing introspectable is lost
10
+ * (test/check-elf-deps.py reads both facts back from the built file).
11
+ */
12
+ {
13
+ global:
14
+ spiel_*;
15
+ local:
16
+ *;
17
+ };
@@ -0,0 +1,285 @@
1
+ // @gjsify/spiel-native — make `gi://Spiel?version=1.0` importable, and say where it
2
+ // came from.
3
+ //
4
+ // This package contains no speech API. It ships libspiel (LGPL-2.1-or-later) as an
5
+ // ADR 0017 prebuild and exposes exactly two things to the package that does — the
6
+ // sibling `@gjsify/speech-synthesis`: {@link probeSpiel}, which answers
7
+ // system / prebuild / absent with a reason, and {@link requireSpiel}, which is the
8
+ // same answer as a namespace or a throw. Nothing else here is public API.
9
+ //
10
+ // ── WHY A PROBE AND NOT AN IMPORT ─────────────────────────────────────────────
11
+ //
12
+ // A static `import Spiel from 'gi://Spiel'` fails the whole module when the typelib
13
+ // is absent, and "absent" is an ORDINARY state here: this prebuild exists only for
14
+ // linux-x64 and linux-arm64, and even on those a consumer may have installed the
15
+ // bridge without its optional per-target package. A speech API that cannot be
16
+ // imported on a machine with no speech is the wrong failure — the W3C surface it
17
+ // backs answers with an empty voice list, which needs the module to load.
18
+ //
19
+ // ── SYSTEM BEFORE PREBUILD, AND WHY IT TAKES WORK ─────────────────────────────
20
+ //
21
+ // A host libspiel wins over ours. It is the distribution's, it is the one a
22
+ // provider package was built against, and it gets security updates we do not ship.
23
+ //
24
+ // That is NOT the default, and that is the whole reason this function exists.
25
+ // Every mechanism that registers an ADR 0017 prebuild PREPENDS it —
26
+ // `activateNativePrebuilds()` (`@gjsify/cli`) calls `prepend_search_path`, the
27
+ // `--app gjs` bundle prologue (`gi-runtime-paths.ts`) emits the same call, and the
28
+ // `gjsify` launcher puts the directory at the FRONT of `GI_TYPELIB_PATH`. All three
29
+ // are right for a bridge whose library exists nowhere else (ADR 0023's
30
+ // bundle-before-system precedence); for a library the host may genuinely have, the
31
+ // precedence is backwards. So the system directory is prepended AGAIN, after them,
32
+ // and therefore ahead of them.
33
+ //
34
+ // WHERE "the system directory" comes from, without guessing: the directory the
35
+ // RUNNING process loaded `GLib-2.0.typelib` from. GJS has GLib loaded before any
36
+ // of our code runs and its typelib is never a prebuild, so that directory is the
37
+ // host's own `girepository-1.0` — the compile-time default, or whatever
38
+ // `GI_TYPELIB_PATH` the user set, with no table of `/usr/lib64`-shaped candidates
39
+ // to keep current per architecture and distribution.
40
+ //
41
+ // ── WHAT THIS DOES NOT DO ─────────────────────────────────────────────────────
42
+ //
43
+ // It does not start a `Spiel.Speaker`, which would open the session bus and
44
+ // D-Bus-ACTIVATE every installed speech provider. `probeSpiel()` is a capability
45
+ // query, in the shape `hasGamepadBackend()` established: it says the namespace is
46
+ // usable, not that this host has a voice. Whether any provider answers is a
47
+ // separate question, and on a Flatpak-sandboxed host the answer is usually no —
48
+ // README.md § Provider discovery has the measurement.
49
+
50
+ import { loadOptionalNativeModule, type GiImportsView } from '@gjsify/utils/core';
51
+
52
+ import type { SpielNamespace } from './namespace.js';
53
+
54
+ export type {
55
+ SpielListModel,
56
+ SpielNamespace,
57
+ SpielProvider,
58
+ SpielSpeaker,
59
+ SpielSpeakerSignals,
60
+ SpielUtterance,
61
+ SpielVoice,
62
+ } from './namespace.js';
63
+
64
+ /** The GI namespace this package makes importable. */
65
+ export const SPIEL_NAMESPACE = 'Spiel';
66
+
67
+ /** The only version libspiel has ever published a typelib for. */
68
+ export const SPIEL_VERSION = '1.0';
69
+
70
+ /** The typelib file name both halves of the search look for. */
71
+ const SPIEL_TYPELIB = `${SPIEL_NAMESPACE}-${SPIEL_VERSION}.typelib`;
72
+
73
+ /** The namespace whose loaded typelib locates the host's own `girepository-1.0`. */
74
+ const ANCHOR_NAMESPACE = 'GLib';
75
+
76
+ /**
77
+ * Where the `Spiel` typelib came from.
78
+ *
79
+ * - `system` — the host's own libspiel, on the directory its GLib typelib lives in.
80
+ * Preferred: it is the build every installed speech provider was made against.
81
+ * - `prebuild` — this package's `@gjsify/spiel-native-<target>` artifact, or any other
82
+ * directory a caller put on girepository's search path.
83
+ * - `absent` — no typelib, or one whose library will not open. {@link SpielProbe.reason}
84
+ * says which.
85
+ */
86
+ export type SpielSource = 'system' | 'prebuild' | 'absent';
87
+
88
+ /** What {@link probeSpiel} found. */
89
+ export interface SpielProbe {
90
+ readonly source: SpielSource;
91
+ /** The namespace, or `null` exactly when `source` is `absent`. */
92
+ readonly module: SpielNamespace | null;
93
+ /** The directory the typelib was loaded from; `null` when none was. */
94
+ readonly typelibDir: string | null;
95
+ /**
96
+ * One sentence naming what is missing — `null` exactly when `source` is not
97
+ * `absent`. Safe to show a user: it names the file and, for a library that was
98
+ * found and refused, the dependency the loader could not resolve.
99
+ */
100
+ readonly reason: string | null;
101
+ /** The underlying error behind `reason`, for a caller that wants to log a cause. */
102
+ readonly error: Error | null;
103
+ }
104
+
105
+ /** Overrides. Tests only — every default here reads the running GJS host. */
106
+ export interface ProbeSpielOptions {
107
+ /** The `imports.gi` to use. */
108
+ readonly gi?: GiImportsView | undefined;
109
+ /** Skip the memo, so a spec can drive more than one outcome per process. */
110
+ readonly fresh?: boolean;
111
+ }
112
+
113
+ /** girepository's two writable search paths, plus the reader the probe needs. */
114
+ interface RepositoryPaths {
115
+ get_typelib_path(namespace: string): string | null;
116
+ prepend_search_path(directory: string): void;
117
+ prepend_library_path(directory: string): void;
118
+ }
119
+
120
+ interface GiWithRepository extends GiImportsView {
121
+ readonly versions?: Record<string, string>;
122
+ }
123
+
124
+ /** The host's `imports.gi`, or `undefined` off GJS. */
125
+ function hostGi(): GiWithRepository | undefined {
126
+ return (globalThis as { imports?: { gi?: GiWithRepository } }).imports?.gi;
127
+ }
128
+
129
+ /** Directory part of a HOST path — both separators, since a win32 host uses `\`. */
130
+ function directoryOf(path: string): string | null {
131
+ return /^(.*)[\\/][^\\/]*$/.exec(path)?.[1] ?? null;
132
+ }
133
+
134
+ function defaultRepository(gi: GiImportsView | undefined): RepositoryPaths | null {
135
+ try {
136
+ // `dup_default()` is GLib >= 2.86, probed as a capability: an older host
137
+ // simply keeps whatever precedence its environment already gives it, which
138
+ // is the behaviour before this module existed.
139
+ return (gi?.GIRepository?.Repository?.dup_default?.() as RepositoryPaths | undefined) ?? null;
140
+ } catch {
141
+ // `imports.gi.GIRepository` LOADS a namespace, and distributions ship that
142
+ // typelib apart from the girepository library GJS links against (Debian's
143
+ // `gir1.2-girepository-3.0` vs `libgirepository-2.0-0`).
144
+ return null;
145
+ }
146
+ }
147
+
148
+ /**
149
+ * Put the host's own typelib directory ahead of every prepended prebuild, when a
150
+ * system `Spiel-1.0.typelib` is really there.
151
+ *
152
+ * Conditional on the FILE, not on the directory: prepending unconditionally would
153
+ * reorder the search path for every other namespace in the process to no purpose,
154
+ * and a change with no effect is one nobody can reason about later.
155
+ *
156
+ * @returns the system typelib directory when it holds Spiel, else `null`
157
+ */
158
+ function preferSystemSpiel(gi: GiImportsView | undefined, repository: RepositoryPaths | null): string | null {
159
+ const glib = gi?.GLib;
160
+ if (!repository || !glib) return null;
161
+ try {
162
+ const anchor = repository.get_typelib_path(ANCHOR_NAMESPACE);
163
+ const dir = anchor ? directoryOf(anchor) : null;
164
+ if (!dir) return null;
165
+ const separator = anchor?.includes('\\') && !anchor.includes('/') ? '\\' : '/';
166
+ if (!glib.file_test(`${dir}${separator}${SPIEL_TYPELIB}`, glib.FileTest.EXISTS)) return null;
167
+ repository.prepend_search_path(dir);
168
+ // The library too: a system libspiel in the default libdir resolves through
169
+ // ld.so anyway, but a host whose GI stack sits under its own prefix
170
+ // (`/opt/…`, jhbuild) has that libdir on neither path. `girLibraryDir` is
171
+ // the INSTALL layout — `<libdir>/girepository-1.0/` beside `<libdir>/`.
172
+ const libDir = directoryOf(dir);
173
+ if (libDir) repository.prepend_library_path(libDir);
174
+ return dir;
175
+ } catch {
176
+ // Every call is girepository reading state this process already holds; a
177
+ // host where it throws has no system preference to express, which leaves
178
+ // the prebuild — the state before this call.
179
+ return null;
180
+ }
181
+ }
182
+
183
+ let memo: SpielProbe | null = null;
184
+
185
+ /**
186
+ * Find `Spiel`, system first, and say what was found.
187
+ *
188
+ * Memoized: the answer cannot change within a process (girepository caches a loaded
189
+ * typelib, and the directories were decided at the first call), and the search path
190
+ * must not be rewritten on every query. Never throws — `absent` is an answer, not a
191
+ * fault, and a caller deciding how to degrade must not have to guard the question.
192
+ */
193
+ export function probeSpiel(options: ProbeSpielOptions = {}): SpielProbe {
194
+ if (memo && !options.fresh) return memo;
195
+
196
+ // `GiWithRepository` rather than `GiImportsView`: `imports.gi.versions` is a GJS
197
+ // property the shared view does not model, and an injected `gi` from a test is
198
+ // allowed to carry it too.
199
+ const gi: GiWithRepository | undefined = options.gi ?? hostGi();
200
+ const repository = defaultRepository(gi);
201
+ const systemDir = preferSystemSpiel(gi, repository);
202
+
203
+ // Pin the version before the namespace resolves. libspiel has published exactly
204
+ // one typelib version, so today this changes nothing; it is here so that a host
205
+ // which later has two does not silently get the other one, which is the failure
206
+ // `gi://Ns` without `?version=` is known for.
207
+ try {
208
+ if (gi?.versions) gi.versions[SPIEL_NAMESPACE] = SPIEL_VERSION;
209
+ } catch {
210
+ // A frozen or absent `versions` table is not a reason to stop looking.
211
+ }
212
+
213
+ const loaded = loadOptionalNativeModule<SpielNamespace>(
214
+ SPIEL_NAMESPACE,
215
+ // Touched after the library opens, so a namespace that resolved while its
216
+ // library did not reads as absent here rather than throwing at first use.
217
+ ['Speaker', 'Utterance'],
218
+ gi,
219
+ );
220
+
221
+ const typelibDir = (() => {
222
+ try {
223
+ const path = repository?.get_typelib_path(SPIEL_NAMESPACE) ?? null;
224
+ return path ? directoryOf(path) : null;
225
+ } catch {
226
+ return null;
227
+ }
228
+ })();
229
+
230
+ const probe: SpielProbe = loaded.module
231
+ ? {
232
+ // Compared against the directory we prepended, not against a path
233
+ // shape: "system" means THIS host's girepository-1.0, and that is the
234
+ // only directory anything here can say that about.
235
+ source: systemDir !== null && typelibDir === systemDir ? 'system' : 'prebuild',
236
+ module: loaded.module,
237
+ typelibDir,
238
+ reason: null,
239
+ error: null,
240
+ }
241
+ : {
242
+ source: 'absent',
243
+ module: null,
244
+ typelibDir,
245
+ reason: absentReason(loaded.error),
246
+ error: loaded.error,
247
+ };
248
+
249
+ if (!options.fresh) memo = probe;
250
+ return probe;
251
+ }
252
+
253
+ /**
254
+ * One sentence a user can act on.
255
+ *
256
+ * Three causes, and they need different sentences: not on GJS at all, no typelib
257
+ * anywhere, and a typelib whose library was found and refused. The third already
258
+ * carries the loader's own text (`NativeLibraryLoadError`), so it is passed through
259
+ * rather than summarised — the dependency it names is the actionable part.
260
+ */
261
+ function absentReason(error: Error | null): string {
262
+ if (!error) return `${SPIEL_NAMESPACE}: not available, and nothing was measurable about why.`;
263
+ if (error.name === 'NativeLibraryLoadError') return error.message;
264
+ if (error.message.includes('imports.gi')) {
265
+ return `${SPIEL_NAMESPACE}: no GObject-Introspection in this process — @gjsify/spiel-native needs GJS or @gjsify/node-gi.`;
266
+ }
267
+ return (
268
+ `${SPIEL_NAMESPACE}-${SPIEL_VERSION}.typelib was not found. Install libspiel, or the ` +
269
+ '`@gjsify/spiel-native-<os>-<arch>` package for this machine — it is an optional ' +
270
+ 'dependency and exists for linux-x64 and linux-arm64 only.'
271
+ );
272
+ }
273
+
274
+ /**
275
+ * {@link probeSpiel} as a namespace or a throw — for a caller that has already
276
+ * decided Spiel is required and wants the reason in the stack trace.
277
+ *
278
+ * The `cause` carries the original error, so a loader fault keeps the file and the
279
+ * missing dependency the probe measured.
280
+ */
281
+ export function requireSpiel(options: ProbeSpielOptions = {}): SpielNamespace {
282
+ const probe = probeSpiel(options);
283
+ if (probe.module) return probe.module;
284
+ throw new Error(probe.reason ?? `${SPIEL_NAMESPACE}: not available.`, { cause: probe.error ?? undefined });
285
+ }
@@ -0,0 +1,110 @@
1
+ // The `gi://Spiel` surface, as types.
2
+ //
3
+ // HAND-WRITTEN rather than generated, on `@gjsify/gamepad`'s precedent
4
+ // (`sdl-namespace.ts`): no `@girs/*` package binds Spiel-1.0 — there is no
5
+ // `@girs/spiel-1.0` on npm — and the namespace is five classes. Read off the GIR
6
+ // this package's own build produces, so a bump that changes the API changes this
7
+ // file in the same commit.
8
+ //
9
+ // Deliberately INCOMPLETE where upstream is deprecated or unusable from JS:
10
+ //
11
+ // · `Provider.get_well_known_name()` is deprecated since libspiel 1.0.4 and GJS
12
+ // prints a warning on every call. `identifier` is the same string and is not.
13
+ // · `Speaker:sink` is `introspectable="0"` in the GIR, so `speaker.sink = el`
14
+ // is a SILENT no-op in GJS. The only spelling that works is
15
+ // `set_property('sink', el)`, which lives on GObject.Object and therefore is
16
+ // not re-declared here — see README.md § No audible output.
17
+
18
+ /** A `Gio.ListModel` of `T`, as the two members a consumer of this package uses. */
19
+ export interface SpielListModel<T> {
20
+ get_n_items(): number;
21
+ get_item(position: number): T | null;
22
+ }
23
+
24
+ /** `Spiel.Voice` — one voice offered by one provider. */
25
+ export interface SpielVoice {
26
+ get_name(): string;
27
+ /** Stable within a provider; the half of a voice id that is not the provider. */
28
+ get_identifier(): string;
29
+ /** BCP-47 tags this voice speaks. */
30
+ get_languages(): string[];
31
+ get_provider(): SpielProvider | null;
32
+ /** `SpeechProvider.VoiceFeature` flags, as the plain bitfield GJS sees. */
33
+ get_features(): number;
34
+ hash(): number;
35
+ equal(other: SpielVoice): boolean;
36
+ compare(other: SpielVoice, ...rest: unknown[]): number;
37
+ }
38
+
39
+ /** `Spiel.Provider` — one speech-provider daemon on the session bus. */
40
+ export interface SpielProvider {
41
+ /** The D-Bus well-known name, e.g. `org.espeak.Speech.Provider`. */
42
+ get_identifier(): string;
43
+ get_name(): string;
44
+ get_voices(): SpielListModel<SpielVoice> | null;
45
+ }
46
+
47
+ /** `Spiel.Utterance` — a request to speak, with its per-utterance settings. */
48
+ export interface SpielUtterance {
49
+ get_text(): string | null;
50
+ set_text(text: string | null): void;
51
+ get_language(): string | null;
52
+ set_language(language: string | null): void;
53
+ get_voice(): SpielVoice | null;
54
+ set_voice(voice: SpielVoice | null): void;
55
+ /** 0..10, 1 is the voice's own. */
56
+ get_rate(): number;
57
+ set_rate(rate: number): void;
58
+ get_pitch(): number;
59
+ set_pitch(pitch: number): void;
60
+ get_volume(): number;
61
+ set_volume(volume: number): void;
62
+ get_is_ssml(): boolean;
63
+ set_is_ssml(isSsml: boolean): void;
64
+ }
65
+
66
+ /** The signals `Spiel.Speaker` emits, with the arguments each carries after the speaker. */
67
+ export interface SpielSpeakerSignals {
68
+ 'utterance-started': [utterance: SpielUtterance];
69
+ 'utterance-finished': [utterance: SpielUtterance];
70
+ 'utterance-canceled': [utterance: SpielUtterance];
71
+ 'utterance-error': [utterance: SpielUtterance, error: unknown];
72
+ 'word-started': [utterance: SpielUtterance, start: number, end: number];
73
+ 'sentence-started': [utterance: SpielUtterance, start: number, end: number];
74
+ 'range-started': [utterance: SpielUtterance, start: number, end: number];
75
+ 'mark-reached': [utterance: SpielUtterance, name: string];
76
+ }
77
+
78
+ /** `Spiel.Speaker` — the client. One per process is enough; it owns the registry. */
79
+ export interface SpielSpeaker {
80
+ speak(utterance: SpielUtterance): void;
81
+ pause(): void;
82
+ resume(): void;
83
+ cancel(): void;
84
+ get_voices(): SpielListModel<SpielVoice> | null;
85
+ get_providers(): SpielListModel<SpielProvider> | null;
86
+
87
+ /** GObject.Object — the only way to reach the `introspectable="0"` `sink`. */
88
+ set_property(name: string, value: unknown): void;
89
+ get_property(name: string): unknown;
90
+ connect<K extends keyof SpielSpeakerSignals>(
91
+ signal: K,
92
+ callback: (speaker: SpielSpeaker, ...args: SpielSpeakerSignals[K]) => void,
93
+ ): number;
94
+ connect(signal: string, callback: (...args: never[]) => void): number;
95
+ disconnect(id: number): void;
96
+ }
97
+
98
+ /** The `gi://Spiel?version=1.0` namespace — the subset this package answers for. */
99
+ export interface SpielNamespace {
100
+ readonly Speaker: {
101
+ /** Blocks until the registry has answered the bus. Throws on failure. */
102
+ new_sync(cancellable?: unknown): SpielSpeaker;
103
+ new (cancellable: unknown, callback: (source: unknown, result: unknown) => void): void;
104
+ new_finish(result: unknown): SpielSpeaker;
105
+ };
106
+ readonly Utterance: { new (props?: Record<string, unknown>): SpielUtterance };
107
+ readonly Provider: object;
108
+ readonly Voice: object;
109
+ readonly VoicesListModel: object;
110
+ }
@@ -0,0 +1,23 @@
1
+ # libspeechprovider (LGPL-2.1-or-later, docs/attribution.md) — the D-Bus
2
+ # interface and audio-stream format shared by libspiel and every speech provider.
3
+ # Linked STATICALLY into libspiel-1.0.so here, so this wrap produces no shipped
4
+ # artifact of its own; see meson.build § ONE library.
5
+ #
6
+ # OUR OWN COPY, at the TOP LEVEL, and both halves of that are load-bearing:
7
+ #
8
+ # · Top level, because libspiel resolves the interface XML through
9
+ # `meson.global_source_root() / 'subprojects' / 'libspeechprovider'`
10
+ # (libspiel/meson.build:111 at the pinned revision). A nested
11
+ # subprojects/libspiel/subprojects/libspeechprovider would need meson's
12
+ # subproject promotion to land in that path; declaring it here is the same
13
+ # result with nothing implicit.
14
+ # · Ours, because upstream's wrap says `revision = main` — an unpinned
15
+ # dependency makes the prebuild un-reproducible, which docs/prebuilds.md
16
+ # forbids. This is 1.0.3, the floor libspiel's `dependency()` requires.
17
+ [wrap-git]
18
+ directory = libspeechprovider
19
+ url = https://github.com/project-spiel/libspeechprovider.git
20
+ revision = 54b6013c2529e208e6a3385076e686fa1c71ef93
21
+
22
+ [provide]
23
+ dependency_names = speech-provider-1.0
@@ -0,0 +1,19 @@
1
+ # libspiel — the Spiel speech-synthesis CLIENT library (LGPL-2.1-or-later,
2
+ # docs/attribution.md). It talks D-Bus to speech providers, so this build exists
3
+ # on Linux only; see README.md § What this is.
4
+ #
5
+ # PINNED TO A COMMIT, not a tag, and that is a measured choice rather than a
6
+ # preference: the newest upstream tag is SPIEL_1_0_4 (2025-04-03), while
7
+ # `meson.build` has said `version: '1.0.5'` since, and 1.0.4 predates the
8
+ # subproject-tracking fix this build needs (libspiel's own meson.build copies the
9
+ # provider interface XML through a custom_target for meson < 1.14.0). There is no
10
+ # SPIEL_1_0_5 to pin. Bumping it: change `revision`, rebuild, re-run
11
+ # `meson test -C build` (the ELF read-back asserts the exported symbol set) and
12
+ # re-measure the prebuild size in README.md § Updating libspiel.
13
+ #
14
+ # No `depth`: a shallow clone can only fetch a commit the server offers as a tip,
15
+ # and this revision is not one.
16
+ [wrap-git]
17
+ directory = libspiel
18
+ url = https://github.com/project-spiel/libspiel.git
19
+ revision = 7c88081b03ebb2903817cf101e4b9906052155c9
@@ -0,0 +1,74 @@
1
+ #!/usr/bin/env python3
2
+ """What `libspiel-1.0.so` links and exports, read back from the built file.
3
+
4
+ Two claims meson.build makes, neither of which the build log can prove:
5
+
6
+ 1. DT_NEEDED names only libc and the GNOME libraries the host provides
7
+ (GLib/GObject/GIO and GStreamer). libspeechprovider must NOT appear: it is
8
+ linked statically (meson.build § ONE library), and a NEEDED entry would mean
9
+ the prebuild is half a set and cannot load without a libspeechprovider
10
+ package — the one thing this build exists to avoid.
11
+ 2. The dynamic symbol table exports only `spiel_*`. ELF has ONE flat symbol
12
+ namespace, and the statically linked libspeechprovider's seven
13
+ `speech_provider_*` functions would otherwise interpose on (or be
14
+ interposed by) the real `libspeechprovider.so` that a speech PROVIDER in the
15
+ same process links. Measured: 89/0 with src/c/spiel.map, 89/7 without.
16
+
17
+ Prints both lists so the CI log carries the measurement, and exits 1 on any
18
+ entry outside them. Uses `readelf` (binutils): the file format is the contract,
19
+ not whatever meson was asked to do.
20
+ """
21
+ import re
22
+ import subprocess
23
+ import sys
24
+
25
+ # GStreamer is a load-time dependency here, unlike in the sibling bridges: Spiel
26
+ # plays a provider's audio stream through a GStreamer pipeline, so libgstreamer,
27
+ # libgstaudio and libgstbase are part of what `gi://Spiel` needs present.
28
+ ALLOWED_NEEDED = re.compile(
29
+ r"^(libc\.so\.6|libm\.so\.6|libdl\.so\.2|libpthread\.so\.0|ld-linux[-\w.]*\.so\.\d+"
30
+ r"|libglib-2\.0\.so\.0|libgobject-2\.0\.so\.0|libgio-2\.0\.so\.0"
31
+ r"|libgstreamer-1\.0\.so\.0|libgstaudio-1\.0\.so\.0|libgstbase-1\.0\.so\.0)$"
32
+ )
33
+
34
+ # The Spiel API as of the pinned revision. A floor rather than an equality: a
35
+ # libspiel bump that ADDS API must not fail this, while a version script that
36
+ # stopped being applied (or a build that exported nothing) must.
37
+ MIN_SPIEL_EXPORTS = 80
38
+
39
+
40
+ def readelf(*args):
41
+ return subprocess.run(["readelf", "--wide", *args], check=True, capture_output=True, text=True).stdout
42
+
43
+
44
+ def main(path):
45
+ needed = re.findall(r"\(NEEDED\)\s+Shared library: \[([^\]]+)\]", readelf("-d", path))
46
+ exported = []
47
+ for line in readelf("--dyn-syms", path).splitlines():
48
+ cols = line.split()
49
+ # Num: Value Size Type Bind Vis Ndx Name — defined (Ndx != UND), GLOBAL/WEAK.
50
+ if len(cols) >= 8 and cols[4] in ("GLOBAL", "WEAK") and cols[6] != "UND":
51
+ exported.append(cols[7].split("@")[0])
52
+
53
+ spiel = [s for s in exported if s.startswith("spiel_")]
54
+ other = [s for s in exported if not s.startswith("spiel_")]
55
+ print(f"{path}")
56
+ print(f" NEEDED ({len(needed)}): {', '.join(needed)}")
57
+ print(f" exported ({len(exported)}): {len(spiel)} spiel_*" + (f", plus {other}" if other else ""))
58
+
59
+ problems = [f"NEEDED {n} is not an OS/GNOME library" for n in needed if not ALLOWED_NEEDED.match(n)]
60
+ problems += [f"exports {s}, which is not spiel_*" for s in other]
61
+ # An empty parse would pass everything above: the library links libc and
62
+ # exports the whole Spiel API, so neither list can be short.
63
+ if not needed or len(spiel) < MIN_SPIEL_EXPORTS:
64
+ problems.append(
65
+ f"parsed {len(needed)} NEEDED / {len(spiel)} spiel_* exports — "
66
+ "either the version script hid the API or readelf's format moved"
67
+ )
68
+ for p in problems:
69
+ print(f" FAIL: {p}")
70
+ return 1 if problems else 0
71
+
72
+
73
+ if __name__ == "__main__":
74
+ sys.exit(main(sys.argv[1]))
@@ -0,0 +1,59 @@
1
+ // The shipped PAIR under GJS: `Spiel-1.0.typelib` resolving, and the library it
2
+ // names by bare leaf opening from beside it. The only two facts a prebuild has
3
+ // to deliver — everything Spiel then does is upstream's.
4
+ //
5
+ // NOTHING HERE TOUCHES THE SESSION BUS, by design and not by caution:
6
+ //
7
+ // · A `Spiel.Speaker` enumerates providers over D-Bus and asks each for its
8
+ // voices, which D-BUS-ACTIVATES them — this test would start speech-provider
9
+ // daemons on the machine running it, and would answer differently on a
10
+ // runner that has none. The live end-to-end check is `speak-fakesink.gjs.js`,
11
+ // opt-in behind GJSIFY_SPIEL_LIVE=1.
12
+ // · Registering the GObject TYPES is already the whole load question: every
13
+ // `*_get_type()` below is a symbol girepository has to find in
14
+ // `libspiel-1.0.so`, so a typelib naming a leaf that is not beside it, or a
15
+ // version script that hid the API, fails right here.
16
+ //
17
+ // Run by meson with GI_TYPELIB_PATH/LD_LIBRARY_PATH pointing at the build
18
+ // directory, which is the one place all three artifacts sit together.
19
+
20
+ import GObject from 'gi://GObject?version=2.0';
21
+ import Spiel from 'gi://Spiel?version=1.0';
22
+
23
+ /** @param {boolean} ok @param {string} what */
24
+ function assert(ok, what) {
25
+ if (!ok) throw new Error(`FAIL: ${what}`);
26
+ console.log(` ok ${what}`);
27
+ }
28
+
29
+ // Resolving `gi://Spiel` loaded the TYPELIB only. Each `$gtype` read below runs
30
+ // the type-init function out of the library, so this is where it opens.
31
+ for (const name of ['Speaker', 'Utterance', 'Voice', 'Provider', 'VoicesListModel']) {
32
+ assert(typeof Spiel[name]?.$gtype === 'object', `Spiel.${name} registers its GType from libspiel-1.0.so`);
33
+ }
34
+
35
+ // A real call through the library, not just type registration: an Utterance is
36
+ // a plain GObject with no bus and no provider behind it.
37
+ const utterance = new Spiel.Utterance({ text: 'prebuild check', pitch: 1.0, rate: 1.0 });
38
+ assert(utterance.text === 'prebuild check', 'Spiel.Utterance round-trips its text property');
39
+
40
+ // THE SILENCE SWITCH, asserted rather than described. `Speaker:sink` is what
41
+ // redirects synthesised audio away from the speakers (README.md § No audible
42
+ // output); the property is marked `introspectable="0"` in the GIR, so
43
+ // `speaker.sink = element` is a SILENT no-op in GJS and `set_property` is the
44
+ // only spelling that works. What this checks is that the property is on the
45
+ // class at all — if a libspiel bump removes it, every caller silently starts
46
+ // playing out loud.
47
+ //
48
+ // `Spiel.Speaker.find_property` and not `GObject.type_class_ref(…)`: the latter
49
+ // answers a boxed `GObject.TypeClass`, which carries none of `GObjectClass`'s
50
+ // methods in GJS (`.find_property is not a function`). GJS puts the class-struct
51
+ // methods on the CLASS instead, and they need no instance — which is the whole
52
+ // point here, since a Speaker cannot be constructed without a session bus.
53
+ assert(Spiel.Speaker.find_property('sink') !== null, 'Spiel.Speaker has the `sink` property');
54
+
55
+ for (const signal of ['utterance-started', 'utterance-finished', 'utterance-error']) {
56
+ assert(GObject.signal_lookup(signal, Spiel.Speaker.$gtype) !== 0, `Spiel.Speaker emits \`${signal}\``);
57
+ }
58
+
59
+ console.log('load-typelib: Spiel-1.0.typelib resolved and libspiel-1.0.so opened beside it');