@gjsify/media-capture 0.0.0-stage → 0.64.14

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.
package/README.md CHANGED
@@ -1,3 +1,53 @@
1
- # Temporary Holding Version
1
+ # @gjsify/media-capture
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ The GStreamer capture chain for GJS: pick a microphone or camera source that actually opens, bind a device to it, express constraints as caps, and read 16 kHz mono PCM for speech recognition. [`@gjsify/webrtc`](../webrtc) builds `getUserMedia` on it; extracted from there per [ADR 0127](../../../docs/adr/0127-speech-recognition-on-local-engines.md). Tier 3 (experimental, [ADR 0003](../../../docs/adr/0003-package-tiering.md)) until promoted. GJS only.
4
+
5
+ Part of the [gjsify](https://github.com/gjsify/gjsify) project — Node.js and Web APIs for GJS (GNOME JavaScript).
6
+
7
+ ## Installation
8
+
9
+ ```bash
10
+ gjsify install @gjsify/media-capture
11
+ ```
12
+
13
+ Needs GStreamer with `gst-plugins-base` (`GstApp` typelib).
14
+
15
+ ## PCM for speech recognition
16
+
17
+ ```typescript
18
+ import { openPcmSource } from '@gjsify/media-capture';
19
+
20
+ const mic = openPcmSource({ sampleRate: 16000, format: 'S16LE' });
21
+ if (mic.synthetic) console.warn('no microphone opened; this is a test tone');
22
+
23
+ for await (const chunk of mic) {
24
+ // chunk: Uint8Array of interleaved little-endian mono samples
25
+ recognizer.feed(chunk);
26
+ if (done) break; // leaving the loop stops the pipeline
27
+ }
28
+ ```
29
+
30
+ `source ! audioconvert ! audioresample ! capsfilter ! appsink`. The default source is the capture chain's audio source; `deviceId` selects another, and `source` swaps in any `Gst.Element` (a file, a test tone). `read()` returns the next chunk or `null` at end of stream and rejects on a pipeline error; `stop()` is idempotent. The appsink is polled, never driven by `new-sample`, because that signal fires on GStreamer's streaming thread where GJS refuses to run JS. A reader that falls behind blocks the source (200 buffers deep) instead of losing audio.
31
+
32
+ ## The capture chain
33
+
34
+ ```typescript
35
+ import { openCapture } from '@gjsify/media-capture';
36
+
37
+ const { source, pipeline } = openCapture('video', { width: 640, height: 480, frameRate: 30 });
38
+ ```
39
+
40
+ | Export | Does |
41
+ |---|---|
42
+ | `openCapture(kind, constraints)` | A source in a fresh NULL pipeline, with a capsfilter when a constraint maps to caps |
43
+ | `createAudioSource` / `createVideoSource` | The bare source element |
44
+ | `bindDevice(kind, element, deviceId)` | Writes `deviceId` to whichever of `device`, `target-object`, `path` the element declares; warns once when none |
45
+ | `isTestSource(element)` | True for `audiotestsrc` / `videotestsrc` |
46
+ | `buildAudioCaps` / `buildVideoCaps` | W3C constraints to a caps string |
47
+ | `ensureGstInit`, `excludeUnboundedAutodetectCandidates` | Lazy `Gst.init`; keeps `openalsrc`/`openalsink` out of autodetect |
48
+
49
+ Source order: `pipewiresrc`, `pulsesrc`, `autoaudiosrc` for audio; `pipewiresrc`, `v4l2src`, `autovideosrc` for video; then `audiotestsrc` / `videotestsrc`. A candidate is used only if it starts. The verdict is cached per process, so a device plugged in later is not seen until restart; elements are still made fresh per call.
50
+
51
+ ## License
52
+
53
+ MIT
@@ -0,0 +1 @@
1
+ var e=Object.defineProperty,__name=(t,n)=>e(t,`name`,{value:n,configurable:!0});export{__name};
@@ -0,0 +1 @@
1
+ import"./_virtual/_rolldown/runtime.js";import{Gst as e,ensureGstInit as t}from"./gst-init.js";function openCapture(n,r={}){t();let i=n===`audio`?createAudioSource(r):createVideoSource(r),a=new e.Pipeline;a.add(i);let o=n===`audio`?buildAudioCaps(r):buildVideoCaps(r);if(o){let t=e.ElementFactory.make(`capsfilter`,null);t.caps=e.Caps.from_string(o),a.add(t),i.link(t)}return{source:i,pipeline:a}}function isTestSource(e){let t=e.get_factory()?.get_name();return t===`audiotestsrc`||t===`videotestsrc`}function buildAudioCaps(e){let t=[];return e.sampleRate!=null&&t.push(`rate=${Math.trunc(e.sampleRate)}`),e.channelCount!=null&&t.push(`channels=${Math.trunc(e.channelCount)}`),t.length===0?null:`audio/x-raw,${t.join(`,`)}`}function buildVideoCaps(e){let t=[];return e.width!=null&&t.push(`width=${Math.trunc(e.width)}`),e.height!=null&&t.push(`height=${Math.trunc(e.height)}`),e.frameRate!=null&&t.push(`framerate=${Math.trunc(e.frameRate)}/1`),t.length===0?null:`video/x-raw,${t.join(`,`)}`}function probeSource(t,n){let r=e.ElementFactory.make(t,null);if(!r)return null;let i=e.ElementFactory.make(n,null),a=e.ElementFactory.make(`fakesink`,null);if(!i||!a)return r;let o=new e.Pipeline;o.add(r),o.add(i),o.add(a),r.link(i),i.link(a);let s=o.set_state(e.State.PLAYING);return s!==e.StateChangeReturn.FAILURE&&(s=o.get_state(500*Number(e.MSECOND))[0]),o.set_state(e.State.NULL),r.unlink(i),o.remove(r),s===e.StateChangeReturn.FAILURE?null:r}const n=new Map;function chooseSource(t,r,i,a){let o=n.get(t);if(o)return e.ElementFactory.make(o,null);for(let e of r){let r=probeSource(e,i);if(r)return n.set(t,e),r}return n.set(t,a),e.ElementFactory.make(a,null)}const r=[`device`,`target_object`,`path`],i=new Set;function bindDevice(e,t,n){if(n){for(let e of r)if(e in t){t[e]=n;return}i.has(e)||(i.add(e),console.warn(`getUserMedia: deviceId ${JSON.stringify(n)} ignored — ${t.name} has no device property, so the ${e} source is the host default. getSupportedConstraints() reports deviceId: true.`))}}function createAudioSource(e={}){let t=chooseSource(`audio`,[`pipewiresrc`,`pulsesrc`,`autoaudiosrc`],`audioconvert`,`audiotestsrc`);try{t.is_live=!0}catch{}return n.get(`audio`)===`audiotestsrc`&&(t.wave=0),bindDevice(`audio`,t,e.deviceId),t}function createVideoSource(e={}){let t=chooseSource(`video`,[`pipewiresrc`,`v4l2src`,`autovideosrc`],`videoconvert`,`videotestsrc`);return n.get(`video`)===`videotestsrc`&&(t.is_live=!0,t.pattern=0),bindDevice(`video`,t,e.deviceId),t}export{bindDevice,buildAudioCaps,buildVideoCaps,createAudioSource,createVideoSource,isTestSource,openCapture};
@@ -0,0 +1 @@
1
+ import"./_virtual/_rolldown/runtime.js";import e from"gi://Gst?version=1.0";import t from"gi://GLib?version=2.0";let n=!1;const r=[`openalsrc`,`openalsink`];function excludeUnboundedAutodetectCandidates(n=t.getenv(`GJSIFY_GST_KEEP_OPENAL`)===`1`){if(n)return;let i=e.Registry.get();for(let t of r)i.lookup_feature(t)?.set_rank(e.Rank.NONE)}function ensureGstInit(){n||=(e.init(null),excludeUnboundedAutodetectCandidates(),!0)}export{e as Gst,ensureGstInit,excludeUnboundedAutodetectCandidates};
@@ -0,0 +1 @@
1
+ import{Gst as e,ensureGstInit as t,excludeUnboundedAutodetectCandidates as n}from"./gst-init.js";import{bindDevice as r,buildAudioCaps as i,buildVideoCaps as a,createAudioSource as o,createVideoSource as s,isTestSource as c,openCapture as l}from"./capture-source.js";import{openPcmSource as u}from"./pcm-source.js";export{e as Gst,r as bindDevice,i as buildAudioCaps,a as buildVideoCaps,o as createAudioSource,s as createVideoSource,t as ensureGstInit,n as excludeUnboundedAutodetectCandidates,c as isTestSource,l as openCapture,u as openPcmSource};
@@ -0,0 +1 @@
1
+ import"./_virtual/_rolldown/runtime.js";import{Gst as e,ensureGstInit as t}from"./gst-init.js";import{createAudioSource as n,isTestSource as r}from"./capture-source.js";import i from"gi://GLib?version=2.0";import a from"gi://GstApp?version=1.0";function ensureGstAppLoaded(){if(a.AppSink===void 0)throw Error(`GstApp typelib is unavailable — install gstreamer1-plugins-base (gst-plugins-base)`)}function sleep(e){return new Promise(t=>{i.timeout_add(i.PRIORITY_DEFAULT,e,()=>(t(),i.SOURCE_REMOVE))})}function openPcmSource(i={}){t(),ensureGstAppLoaded();let a=Math.trunc(i.sampleRate??16e3),o=i.format??`S16LE`,s=i.pollIntervalMs??10;if(!(a>0))throw RangeError(`openPcmSource: invalid sampleRate ${i.sampleRate}`);if(o!==`S16LE`&&o!==`F32LE`)throw TypeError(`openPcmSource: unsupported format ${o}`);let c=i.source??n({deviceId:i.deviceId}),l=e.ElementFactory.make(`audioconvert`,null),u=e.ElementFactory.make(`audioresample`,null),d=e.ElementFactory.make(`capsfilter`,null),f=e.ElementFactory.make(`appsink`,null);if(!l||!u||!d||!f)throw Error(`openPcmSource: GStreamer audioconvert, audioresample or appsink is missing (gst-plugins-base)`);d.caps=e.Caps.from_string(`audio/x-raw,format=${o},rate=${a},channels=1,layout=interleaved`);let p=f;p.emit_signals=!1,p.sync=!1,p.max_buffers=200;let m=new e.Pipeline,h=[c,l,u,d,f];for(let e of h)m.add(e);for(let e=0;e<h.length-1;e++)if(!h[e].link(h[e+1]))throw Error(`openPcmSource: cannot link ${h[e].name} to ${h[e+1].name}`);let g=m.get_bus(),_=f,v=!1,y=!1,failIfBusError=()=>{let t=g.pop_filtered(e.MessageType.ERROR);if(!t)return;let[n,r]=t.parse_error();throw Error(`openPcmSource: ${n.message}${r?` (${r})`:``}`)},b={sampleRate:a,format:o,channels:1,label:c.get_factory()?.get_name()??c.name,synthetic:r(c),start(){if(!(v||y)&&(v=!0,m.set_state(e.State.PLAYING)===e.StateChangeReturn.FAILURE))throw failIfBusError(),Error(`openPcmSource: ${b.label} failed to start`)},async read(){for(b.start();!y;){failIfBusError();let t=_.try_pull_sample(0);if(t){let n=t.get_buffer();if(n){let[t,r]=n.map(e.MapFlags.READ);if(t){let e=new Uint8Array(r.data);if(n.unmap(r),e.length>0)return e}}continue}if(_.is_eos())return null;await sleep(s)}return null},stop(){y||(y=!0,m.set_state(e.State.NULL))},async*[Symbol.asyncIterator](){try{for(let e=await b.read();e;e=await b.read())yield e}finally{b.stop()}}};return b}export{openPcmSource};
@@ -0,0 +1,35 @@
1
+ import { Gst } from './gst-init.js';
2
+ export interface CaptureConstraints {
3
+ deviceId?: string;
4
+ sampleRate?: number;
5
+ channelCount?: number;
6
+ width?: number;
7
+ height?: number;
8
+ frameRate?: number;
9
+ }
10
+ export type CaptureKind = 'audio' | 'video';
11
+ /** A capture source inside its own pipeline, constraints applied, not yet started. */
12
+ export interface Capture {
13
+ source: Gst.Element;
14
+ pipeline: Gst.Pipeline;
15
+ }
16
+ /**
17
+ * Open a capture source for `kind` in a fresh pipeline, with the constraints
18
+ * mapped to a capsfilter behind it. The pipeline stays in NULL; the caller
19
+ * decides when to start it.
20
+ */
21
+ export declare function openCapture(kind: CaptureKind, constraints?: CaptureConstraints): Capture;
22
+ /** True when `source` is a synthetic test source, i.e. no capture device opened on this host. */
23
+ export declare function isTestSource(source: Gst.Element): boolean;
24
+ /** Build a GStreamer caps string for audio constraints. */
25
+ export declare function buildAudioCaps(c: CaptureConstraints): string | null;
26
+ /** Build a GStreamer caps string for video constraints. */
27
+ export declare function buildVideoCaps(c: CaptureConstraints): string | null;
28
+ /**
29
+ * Bind a W3C `deviceId` to a source element, reporting when the element cannot
30
+ * express one. A caller that asked for a specific device and silently receives
31
+ * another is the defect #1513 reports, so the unbindable case is never quiet.
32
+ */
33
+ export declare function bindDevice(kind: string, el: Gst.Element, deviceId: string | undefined): void;
34
+ export declare function createAudioSource(constraints?: CaptureConstraints): Gst.Element;
35
+ export declare function createVideoSource(constraints?: CaptureConstraints): Gst.Element;
@@ -0,0 +1,2 @@
1
+ declare const _default: () => Promise<void>;
2
+ export default _default;
@@ -0,0 +1,5 @@
1
+ import Gst from 'gi://Gst?version=1.0';
2
+ /** TEST SEAM (exported) — see the `keepOpenal` parameter doc above. */
3
+ export declare function excludeUnboundedAutodetectCandidates(keepOpenal?: boolean): void;
4
+ export declare function ensureGstInit(): void;
5
+ export { Gst };
@@ -0,0 +1,5 @@
1
+ export { Gst, ensureGstInit, excludeUnboundedAutodetectCandidates } from './gst-init.js';
2
+ export { openCapture, createAudioSource, createVideoSource, bindDevice, isTestSource, buildAudioCaps, buildVideoCaps, } from './capture-source.js';
3
+ export type { Capture, CaptureKind, CaptureConstraints } from './capture-source.js';
4
+ export { openPcmSource } from './pcm-source.js';
5
+ export type { PcmFormat, PcmSource, PcmSourceOptions } from './pcm-source.js';
@@ -0,0 +1,34 @@
1
+ import { Gst } from './gst-init.js';
2
+ export type PcmFormat = 'S16LE' | 'F32LE';
3
+ export interface PcmSourceOptions {
4
+ /** Samples per second. Default 16000, what the recognition engines take. */
5
+ sampleRate?: number;
6
+ /** Sample format. Default `S16LE`. */
7
+ format?: PcmFormat;
8
+ /** A device id as `enumerateDevices()` reports it. Default: the host's default input. */
9
+ deviceId?: string;
10
+ /** Use this element instead of the capture chain — a file, a test tone, a fake. */
11
+ source?: Gst.Element;
12
+ /** How often an empty appsink is polled, in ms. Default 10. */
13
+ pollIntervalMs?: number;
14
+ }
15
+ export interface PcmSource extends AsyncIterable<Uint8Array> {
16
+ readonly sampleRate: number;
17
+ readonly format: PcmFormat;
18
+ readonly channels: 1;
19
+ /** Factory name of the source element, e.g. `pipewiresrc`. */
20
+ readonly label: string;
21
+ /** True when no capture device opened and a test tone stands in. */
22
+ readonly synthetic: boolean;
23
+ /** Start the pipeline. Idempotent; `read()` calls it. Throws if the source cannot start. */
24
+ start(): void;
25
+ /** The next chunk of interleaved little-endian samples, or `null` at end of stream. Rejects on a pipeline error. */
26
+ read(): Promise<Uint8Array | null>;
27
+ /** Stop and release the pipeline. Idempotent; a pending `read()` resolves `null`. */
28
+ stop(): void;
29
+ }
30
+ /**
31
+ * Open a mono PCM source: `source ! audioconvert ! audioresample ! capsfilter ! appsink`.
32
+ * The pipeline is built but not started.
33
+ */
34
+ export declare function openPcmSource(options?: PcmSourceOptions): PcmSource;
@@ -0,0 +1,2 @@
1
+ declare const _default: () => Promise<void>;
2
+ export default _default;
package/package.json CHANGED
@@ -1,6 +1,72 @@
1
1
  {
2
- "name": "@gjsify/media-capture",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
2
+ "name": "@gjsify/media-capture",
3
+ "version": "0.64.14",
4
+ "description": "GStreamer capture chain for GJS (pipewiresrc / pulsesrc / v4l2src with probed fallback) and a 16 kHz mono PCM audio source",
5
+ "type": "module",
6
+ "module": "lib/esm/index.js",
7
+ "types": "lib/types/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./lib/types/index.d.ts",
11
+ "default": "./lib/esm/index.js"
12
+ }
13
+ },
14
+ "files": [
15
+ "lib"
16
+ ],
17
+ "sideEffects": false,
18
+ "scripts": {
19
+ "clear": "gjsify clear lib tsconfig.tsbuildinfo tsconfig.types.tsbuildinfo test.gjs.mjs",
20
+ "check": "gjsify tsc --noEmit",
21
+ "build": "gjsify run build:gjsify && gjsify run build:types",
22
+ "build:gjsify": "gjsify build --library 'src/**/*.{ts,js}' --exclude 'src/**/*.spec.{mts,ts}' 'src/test*.{mts,ts}'",
23
+ "build:types": "gjsify tsc",
24
+ "build:test": "gjsify run build:test:gjs",
25
+ "build:test:gjs": "gjsify build src/test.mts --app gjs --outfile test.gjs.mjs",
26
+ "test": "gjsify run build:gjsify && gjsify run build:test && gjsify run test:gjs",
27
+ "test:gjs": "gjsify run test.gjs.mjs"
28
+ },
29
+ "keywords": [
30
+ "gjs",
31
+ "gstreamer",
32
+ "capture",
33
+ "microphone",
34
+ "camera",
35
+ "pipewire",
36
+ "pcm"
37
+ ],
38
+ "dependencies": {
39
+ "@gjsify/dom-exception": "^0.64.14",
40
+ "@gjsify/node-globals": "^0.64.14"
41
+ },
42
+ "devDependencies": {
43
+ "@girs/gjs": "5.4.0",
44
+ "@girs/glib-2.0": "5.4.0",
45
+ "@girs/gobject-2.0": "5.4.0",
46
+ "@girs/gst-1.0": "5.4.0",
47
+ "@girs/gstapp-1.0": "5.4.0",
48
+ "@gjsify/cli": "^0.64.14",
49
+ "@gjsify/unit": "^0.64.14",
50
+ "@types/node": "^25.9.2",
51
+ "typescript": "^6.0.3"
52
+ },
53
+ "gjsify": {
54
+ "runtimes": {
55
+ "gjs": "polyfill",
56
+ "node": "none",
57
+ "browser": "none",
58
+ "nativescript": "none"
59
+ },
60
+ "tier": 1
61
+ },
62
+ "license": "MIT",
63
+ "repository": {
64
+ "type": "git",
65
+ "url": "git+https://github.com/gjsify/gjsify.git",
66
+ "directory": "packages/web/media-capture"
67
+ },
68
+ "bugs": {
69
+ "url": "https://github.com/gjsify/gjsify/issues"
70
+ },
71
+ "homepage": "https://github.com/gjsify/gjsify/tree/main/packages/web/media-capture#readme"
72
+ }