@gjsify/webaudio 0.1.9

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.
Files changed (45) hide show
  1. package/lib/esm/audio-buffer-source-node.js +63 -0
  2. package/lib/esm/audio-buffer.js +37 -0
  3. package/lib/esm/audio-context.js +94 -0
  4. package/lib/esm/audio-destination-node.js +10 -0
  5. package/lib/esm/audio-node.js +33 -0
  6. package/lib/esm/audio-param.js +78 -0
  7. package/lib/esm/gain-node.js +19 -0
  8. package/lib/esm/gst-decoder.js +64 -0
  9. package/lib/esm/gst-init.js +12 -0
  10. package/lib/esm/gst-player.js +125 -0
  11. package/lib/esm/html-audio-element.js +61 -0
  12. package/lib/esm/index.js +18 -0
  13. package/lib/esm/register.js +13 -0
  14. package/lib/types/audio-buffer-source-node.d.ts +18 -0
  15. package/lib/types/audio-buffer.d.ts +17 -0
  16. package/lib/types/audio-context.d.ts +34 -0
  17. package/lib/types/audio-destination-node.d.ts +5 -0
  18. package/lib/types/audio-node.d.ts +12 -0
  19. package/lib/types/audio-param.d.ts +20 -0
  20. package/lib/types/gain-node.d.ts +9 -0
  21. package/lib/types/gst-decoder.d.ts +9 -0
  22. package/lib/types/gst-init.d.ts +3 -0
  23. package/lib/types/gst-player.d.ts +39 -0
  24. package/lib/types/html-audio-element.d.ts +17 -0
  25. package/lib/types/index.d.ts +8 -0
  26. package/lib/types/register.d.ts +1 -0
  27. package/lib/types/webaudio.spec.d.ts +2 -0
  28. package/package.json +53 -0
  29. package/src/audio-buffer-source-node.ts +84 -0
  30. package/src/audio-buffer.ts +47 -0
  31. package/src/audio-context.ts +102 -0
  32. package/src/audio-destination-node.ts +12 -0
  33. package/src/audio-node.ts +37 -0
  34. package/src/audio-param.ts +103 -0
  35. package/src/gain-node.ts +23 -0
  36. package/src/gst-decoder.ts +102 -0
  37. package/src/gst-init.ts +15 -0
  38. package/src/gst-player.ts +178 -0
  39. package/src/html-audio-element.ts +76 -0
  40. package/src/index.ts +14 -0
  41. package/src/register.ts +17 -0
  42. package/src/test.mts +5 -0
  43. package/src/webaudio.spec.ts +351 -0
  44. package/tsconfig.json +36 -0
  45. package/tsconfig.tsbuildinfo +1 -0
@@ -0,0 +1,34 @@
1
+ import { AudioBuffer } from './audio-buffer.js';
2
+ import { AudioNode } from './audio-node.js';
3
+ import { AudioDestinationNode } from './audio-destination-node.js';
4
+ import { AudioBufferSourceNode } from './audio-buffer-source-node.js';
5
+ import { GainNode } from './gain-node.js';
6
+ export declare class AudioContext {
7
+ state: AudioContextState;
8
+ readonly sampleRate = 44100;
9
+ readonly destination: AudioDestinationNode;
10
+ readonly listener: {};
11
+ private _startTime;
12
+ constructor();
13
+ /** Monotonically increasing time in seconds since context creation. */
14
+ get currentTime(): number;
15
+ createGain(): GainNode;
16
+ createBufferSource(): AudioBufferSourceNode;
17
+ createBuffer(numberOfChannels: number, length: number, sampleRate: number): AudioBuffer;
18
+ /**
19
+ * Decode encoded audio data (MP3, WAV, OGG, etc.) into an AudioBuffer.
20
+ * Uses GStreamer's decodebin for format-agnostic decoding.
21
+ */
22
+ decodeAudioData(arrayBuffer: ArrayBuffer, successCallback?: (buffer: AudioBuffer) => void, errorCallback?: (error: DOMException) => void): Promise<AudioBuffer>;
23
+ resume(): Promise<void>;
24
+ suspend(): Promise<void>;
25
+ close(): Promise<void>;
26
+ createAnalyser(): any;
27
+ createDynamicsCompressor(): AudioNode;
28
+ createBiquadFilter(): any;
29
+ createConvolver(): AudioNode;
30
+ createPanner(): AudioNode;
31
+ createStereoPanner(): AudioNode;
32
+ addEventListener(_type: string, _listener: any): void;
33
+ removeEventListener(_type: string, _listener: any): void;
34
+ }
@@ -0,0 +1,5 @@
1
+ import { AudioNode } from './audio-node.js';
2
+ export declare class AudioDestinationNode extends AudioNode {
3
+ readonly maxChannelCount = 2;
4
+ constructor();
5
+ }
@@ -0,0 +1,12 @@
1
+ export declare class AudioNode {
2
+ /** @internal downstream connections */
3
+ _outputs: Set<AudioNode>;
4
+ /** @internal upstream connections */
5
+ _inputs: Set<AudioNode>;
6
+ readonly numberOfInputs: number;
7
+ readonly numberOfOutputs: number;
8
+ readonly channelCount: number;
9
+ constructor(numberOfInputs?: number, numberOfOutputs?: number);
10
+ connect(destination: AudioNode): AudioNode;
11
+ disconnect(destination?: AudioNode): void;
12
+ }
@@ -0,0 +1,20 @@
1
+ export declare class AudioParam {
2
+ readonly defaultValue: number;
3
+ readonly minValue: number;
4
+ readonly maxValue: number;
5
+ /** @internal callback invoked when value changes */
6
+ _onChange: ((value: number) => void) | null;
7
+ private _value;
8
+ private _rampTimerId;
9
+ constructor(defaultValue?: number, minValue?: number, maxValue?: number);
10
+ get value(): number;
11
+ set value(v: number);
12
+ setValueAtTime(value: number, _startTime: number): AudioParam;
13
+ linearRampToValueAtTime(value: number, _endTime: number): AudioParam;
14
+ exponentialRampToValueAtTime(value: number, _endTime: number): AudioParam;
15
+ setTargetAtTime(target: number, _startTime: number, timeConstant: number): AudioParam;
16
+ setValueCurveAtTime(_values: Float32Array, _startTime: number, _duration: number): AudioParam;
17
+ cancelScheduledValues(_startTime: number): AudioParam;
18
+ cancelAndHoldAtTime(_cancelTime: number): AudioParam;
19
+ private _cancelRamp;
20
+ }
@@ -0,0 +1,9 @@
1
+ import { AudioNode } from './audio-node.js';
2
+ import { AudioParam } from './audio-param.js';
3
+ import type { GstPlayer } from './gst-player.js';
4
+ export declare class GainNode extends AudioNode {
5
+ readonly gain: AudioParam;
6
+ /** @internal active players that need volume updates */
7
+ _activePlayers: Set<GstPlayer>;
8
+ constructor();
9
+ }
@@ -0,0 +1,9 @@
1
+ import { AudioBuffer } from './audio-buffer.js';
2
+ /**
3
+ * Decode encoded audio data (MP3, WAV, OGG, FLAC, etc.) into an AudioBuffer
4
+ * containing PCM Float32 channel data.
5
+ *
6
+ * This is a synchronous operation that blocks until decoding completes.
7
+ * It must be called from the main thread (GJS requirement).
8
+ */
9
+ export declare function decodeAudioDataSync(arrayBuffer: ArrayBuffer): AudioBuffer;
@@ -0,0 +1,3 @@
1
+ import Gst from 'gi://Gst?version=1.0';
2
+ export declare function ensureGstInit(): void;
3
+ export { Gst };
@@ -0,0 +1,39 @@
1
+ import type { AudioBuffer } from './audio-buffer.js';
2
+ export interface GstPlayerOptions {
3
+ audioBuffer: AudioBuffer;
4
+ volume: number;
5
+ loop: boolean;
6
+ offset: number;
7
+ duration?: number;
8
+ playbackRate: number;
9
+ onEnded: () => void;
10
+ }
11
+ /**
12
+ * Manages a single GStreamer playback pipeline for one AudioBufferSourceNode.start() call.
13
+ */
14
+ export declare class GstPlayer {
15
+ private _pipeline;
16
+ private _volumeElement;
17
+ private _busWatchId;
18
+ private _ended;
19
+ private _loop;
20
+ private _onEnded;
21
+ private _audioBuffer;
22
+ constructor(options: GstPlayerOptions);
23
+ /** Update volume on a running pipeline */
24
+ setVolume(value: number): void;
25
+ /** Update loop flag */
26
+ setLoop(value: boolean): void;
27
+ /** Stop playback and clean up */
28
+ stop(): void;
29
+ /** Whether playback has ended */
30
+ get ended(): boolean;
31
+ private _restartPlayback;
32
+ private _fireEnded;
33
+ private _cleanup;
34
+ /**
35
+ * Interleave AudioBuffer's per-channel Float32Arrays into a single Uint8Array.
36
+ * Applies offset (seconds) and optional duration (seconds).
37
+ */
38
+ private _interleave;
39
+ }
@@ -0,0 +1,17 @@
1
+ export declare class HTMLAudioElement {
2
+ src: string;
3
+ volume: number;
4
+ loop: boolean;
5
+ paused: boolean;
6
+ currentTime: number;
7
+ duration: number;
8
+ readyState: number;
9
+ private _pipeline;
10
+ canPlayType(type: string): CanPlayTypeResult;
11
+ play(): Promise<void>;
12
+ pause(): void;
13
+ load(): void;
14
+ addEventListener(_type: string, _listener: any): void;
15
+ removeEventListener(_type: string, _listener: any): void;
16
+ private _cleanup;
17
+ }
@@ -0,0 +1,8 @@
1
+ export { AudioContext } from './audio-context.js';
2
+ export { AudioBuffer } from './audio-buffer.js';
3
+ export { AudioNode } from './audio-node.js';
4
+ export { AudioDestinationNode } from './audio-destination-node.js';
5
+ export { AudioBufferSourceNode } from './audio-buffer-source-node.js';
6
+ export { GainNode } from './gain-node.js';
7
+ export { AudioParam } from './audio-param.js';
8
+ export { HTMLAudioElement } from './html-audio-element.js';
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,2 @@
1
+ declare const _default: () => Promise<void>;
2
+ export default _default;
package/package.json ADDED
@@ -0,0 +1,53 @@
1
+ {
2
+ "name": "@gjsify/webaudio",
3
+ "version": "0.1.9",
4
+ "description": "Web Audio API for GJS using GStreamer as audio backend",
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
+ "./register": {
14
+ "types": "./lib/types/register.d.ts",
15
+ "default": "./lib/esm/register.js"
16
+ }
17
+ },
18
+ "sideEffects": [
19
+ "./lib/esm/register.js"
20
+ ],
21
+ "scripts": {
22
+ "clear": "rm -rf lib tsconfig.tsbuildinfo tsconfig.types.tsbuildinfo test.gjs.mjs || exit 0",
23
+ "check": "tsc --noEmit",
24
+ "build": "yarn build:gjsify && yarn build:types",
25
+ "build:gjsify": "gjsify build --library 'src/**/*.{ts,js}' --exclude 'src/**/*.spec.{mts,ts}' 'src/test.{mts,ts}'",
26
+ "build:types": "tsc",
27
+ "build:test": "yarn build:test:gjs",
28
+ "build:test:gjs": "gjsify build src/test.mts --app gjs --outfile test.gjs.mjs",
29
+ "test": "yarn build:gjsify && yarn build:test && yarn test:gjs",
30
+ "test:gjs": "gjs -m test.gjs.mjs"
31
+ },
32
+ "keywords": [
33
+ "gjs",
34
+ "webaudio",
35
+ "gstreamer",
36
+ "audio-api"
37
+ ],
38
+ "dependencies": {
39
+ "@gjsify/dom-events": "^0.1.9"
40
+ },
41
+ "devDependencies": {
42
+ "@girs/gjs": "^4.0.0-rc.2",
43
+ "@girs/glib-2.0": "^2.88.0-4.0.0-rc.2",
44
+ "@girs/gst-1.0": "^1.28.1-4.0.0-rc.2",
45
+ "@girs/gstapp-1.0": "^1.0.0-4.0.0-rc.2",
46
+ "@girs/gstaudio-1.0": "^1.0.0-4.0.0-rc.2",
47
+ "@girs/gstbase-1.0": "^1.0.0-4.0.0-rc.2",
48
+ "@gjsify/cli": "^0.1.9",
49
+ "@gjsify/unit": "^0.1.9",
50
+ "@types/node": "^25.6.0",
51
+ "typescript": "^6.0.2"
52
+ }
53
+ }
@@ -0,0 +1,84 @@
1
+ // AudioBufferSourceNode — single-use audio playback node backed by GStreamer.
2
+ //
3
+ // W3C spec: each source node can only be started once. Excalibur creates a new
4
+ // AudioBufferSourceNode for every play via _createNewBufferSource().
5
+ //
6
+ // Audio graph: AudioBufferSourceNode → GainNode → AudioDestinationNode
7
+ //
8
+ // Reference: https://developer.mozilla.org/en-US/docs/Web/API/AudioBufferSourceNode
9
+
10
+ import { AudioNode } from './audio-node.js';
11
+ import { AudioParam } from './audio-param.js';
12
+ import { GstPlayer } from './gst-player.js';
13
+ import { GainNode } from './gain-node.js';
14
+ import type { AudioBuffer } from './audio-buffer.js';
15
+
16
+ export class AudioBufferSourceNode extends AudioNode {
17
+ buffer: AudioBuffer | null = null;
18
+ loop = false;
19
+ loopStart = 0;
20
+ loopEnd = 0;
21
+ readonly playbackRate: AudioParam;
22
+ onended: (() => void) | null = null;
23
+
24
+ private _player: GstPlayer | null = null;
25
+ private _started = false;
26
+
27
+ constructor() {
28
+ super(0, 1); // 0 inputs (source node), 1 output
29
+ this.playbackRate = new AudioParam(1, 0.0625, 16);
30
+ }
31
+
32
+ start(when = 0, offset = 0, duration?: number): void {
33
+ if (this._started) {
34
+ throw new DOMException('AudioBufferSourceNode can only be started once', 'InvalidStateError');
35
+ }
36
+ this._started = true;
37
+
38
+ if (!this.buffer) return;
39
+
40
+ // Walk connection chain to find GainNode and its volume
41
+ const gainNode = this._findGainNode();
42
+ const volume = gainNode ? gainNode.gain.value : 1;
43
+
44
+ this._player = new GstPlayer({
45
+ audioBuffer: this.buffer,
46
+ volume,
47
+ loop: this.loop,
48
+ offset,
49
+ duration,
50
+ playbackRate: this.playbackRate.value,
51
+ onEnded: () => {
52
+ // Unregister from GainNode
53
+ if (gainNode) {
54
+ gainNode._activePlayers.delete(this._player!);
55
+ }
56
+ this._player = null;
57
+ this.onended?.();
58
+ },
59
+ });
60
+
61
+ // Register with GainNode for live volume updates
62
+ if (gainNode && this._player) {
63
+ gainNode._activePlayers.add(this._player);
64
+ }
65
+ }
66
+
67
+ stop(_when = 0): void {
68
+ if (this._player) {
69
+ this._player.stop();
70
+ }
71
+ }
72
+
73
+ /** Walk the output chain to find a GainNode */
74
+ private _findGainNode(): GainNode | null {
75
+ for (const node of this._outputs) {
76
+ if (node instanceof GainNode) return node;
77
+ // Check one level deeper (in case of intermediary nodes)
78
+ for (const inner of node._outputs) {
79
+ if (inner instanceof GainNode) return inner;
80
+ }
81
+ }
82
+ return null;
83
+ }
84
+ }
@@ -0,0 +1,47 @@
1
+ // AudioBuffer — holds decoded PCM audio data as per-channel Float32Arrays.
2
+ // Reference: https://developer.mozilla.org/en-US/docs/Web/API/AudioBuffer
3
+
4
+ export interface AudioBufferOptions {
5
+ numberOfChannels: number;
6
+ length: number;
7
+ sampleRate: number;
8
+ }
9
+
10
+ export class AudioBuffer {
11
+ readonly sampleRate: number;
12
+ readonly length: number;
13
+ readonly duration: number;
14
+ readonly numberOfChannels: number;
15
+ /** @internal */
16
+ _channelData: Float32Array[];
17
+
18
+ constructor(options: AudioBufferOptions) {
19
+ this.sampleRate = options.sampleRate;
20
+ this.length = options.length;
21
+ this.numberOfChannels = options.numberOfChannels;
22
+ this.duration = this.length / this.sampleRate;
23
+ this._channelData = [];
24
+ for (let i = 0; i < this.numberOfChannels; i++) {
25
+ this._channelData.push(new Float32Array(this.length));
26
+ }
27
+ }
28
+
29
+ getChannelData(channel: number): Float32Array {
30
+ if (channel < 0 || channel >= this.numberOfChannels) {
31
+ throw new RangeError(`channel index ${channel} out of range [0, ${this.numberOfChannels})`);
32
+ }
33
+ return this._channelData[channel];
34
+ }
35
+
36
+ copyFromChannel(destination: Float32Array, channelNumber: number, bufferOffset = 0): void {
37
+ const src = this.getChannelData(channelNumber);
38
+ const len = Math.min(destination.length, src.length - bufferOffset);
39
+ destination.set(src.subarray(bufferOffset, bufferOffset + len));
40
+ }
41
+
42
+ copyToChannel(source: Float32Array, channelNumber: number, bufferOffset = 0): void {
43
+ const dest = this.getChannelData(channelNumber);
44
+ const len = Math.min(source.length, dest.length - bufferOffset);
45
+ dest.set(source.subarray(0, len), bufferOffset);
46
+ }
47
+ }
@@ -0,0 +1,102 @@
1
+ // AudioContext — top-level Web Audio API entry point backed by GStreamer.
2
+ //
3
+ // Phase 1: covers Excalibur.js needs (decodeAudioData, createBufferSource,
4
+ // createGain, currentTime, resume/suspend/close).
5
+ //
6
+ // Reference: https://developer.mozilla.org/en-US/docs/Web/API/AudioContext
7
+
8
+ import GLib from 'gi://GLib?version=2.0';
9
+ import { ensureGstInit } from './gst-init.js';
10
+ import { AudioBuffer } from './audio-buffer.js';
11
+ import { AudioNode } from './audio-node.js';
12
+ import { AudioDestinationNode } from './audio-destination-node.js';
13
+ import { AudioBufferSourceNode } from './audio-buffer-source-node.js';
14
+ import { GainNode } from './gain-node.js';
15
+ import { decodeAudioDataSync } from './gst-decoder.js';
16
+
17
+ export class AudioContext {
18
+ state: AudioContextState = 'suspended';
19
+ readonly sampleRate = 44100;
20
+ readonly destination: AudioDestinationNode;
21
+ readonly listener = {};
22
+
23
+ private _startTime: number;
24
+
25
+ constructor() {
26
+ ensureGstInit();
27
+ this._startTime = GLib.get_monotonic_time();
28
+ this.destination = new AudioDestinationNode();
29
+ }
30
+
31
+ /** Monotonically increasing time in seconds since context creation. */
32
+ get currentTime(): number {
33
+ return (GLib.get_monotonic_time() - this._startTime) / 1_000_000;
34
+ }
35
+
36
+ createGain(): GainNode {
37
+ return new GainNode();
38
+ }
39
+
40
+ createBufferSource(): AudioBufferSourceNode {
41
+ return new AudioBufferSourceNode();
42
+ }
43
+
44
+ createBuffer(numberOfChannels: number, length: number, sampleRate: number): AudioBuffer {
45
+ return new AudioBuffer({ numberOfChannels, length, sampleRate });
46
+ }
47
+
48
+ /**
49
+ * Decode encoded audio data (MP3, WAV, OGG, etc.) into an AudioBuffer.
50
+ * Uses GStreamer's decodebin for format-agnostic decoding.
51
+ */
52
+ decodeAudioData(
53
+ arrayBuffer: ArrayBuffer,
54
+ successCallback?: (buffer: AudioBuffer) => void,
55
+ errorCallback?: (error: DOMException) => void
56
+ ): Promise<AudioBuffer> {
57
+ try {
58
+ const buffer = decodeAudioDataSync(arrayBuffer);
59
+ successCallback?.(buffer);
60
+ return Promise.resolve(buffer);
61
+ } catch (err) {
62
+ const domErr = err instanceof DOMException
63
+ ? err
64
+ : new DOMException('Unable to decode audio data', 'EncodingError');
65
+ errorCallback?.(domErr);
66
+ return Promise.reject(domErr);
67
+ }
68
+ }
69
+
70
+ async resume(): Promise<void> {
71
+ this.state = 'running';
72
+ }
73
+
74
+ async suspend(): Promise<void> {
75
+ this.state = 'suspended';
76
+ }
77
+
78
+ async close(): Promise<void> {
79
+ this.state = 'closed';
80
+ }
81
+
82
+ // Stub methods for APIs not yet backed by GStreamer (Phase 3)
83
+ createAnalyser(): any {
84
+ return {
85
+ connect: () => {},
86
+ disconnect: () => {},
87
+ fftSize: 2048,
88
+ frequencyBinCount: 1024,
89
+ getByteFrequencyData: () => {},
90
+ getFloatFrequencyData: () => {},
91
+ };
92
+ }
93
+
94
+ createDynamicsCompressor(): AudioNode { return new AudioNode(); }
95
+ createBiquadFilter(): any { return new AudioNode(); }
96
+ createConvolver(): AudioNode { return new AudioNode(); }
97
+ createPanner(): AudioNode { return new AudioNode(); }
98
+ createStereoPanner(): AudioNode { return new AudioNode(); }
99
+
100
+ addEventListener(_type: string, _listener: any): void {}
101
+ removeEventListener(_type: string, _listener: any): void {}
102
+ }
@@ -0,0 +1,12 @@
1
+ // AudioDestinationNode — represents the final audio output (speakers).
2
+ // Reference: https://developer.mozilla.org/en-US/docs/Web/API/AudioDestinationNode
3
+
4
+ import { AudioNode } from './audio-node.js';
5
+
6
+ export class AudioDestinationNode extends AudioNode {
7
+ readonly maxChannelCount = 2;
8
+
9
+ constructor() {
10
+ super(1, 0); // 1 input, 0 outputs (terminal node)
11
+ }
12
+ }
@@ -0,0 +1,37 @@
1
+ // AudioNode — base class for all audio graph nodes.
2
+ // Reference: https://developer.mozilla.org/en-US/docs/Web/API/AudioNode
3
+
4
+ export class AudioNode {
5
+ /** @internal downstream connections */
6
+ _outputs: Set<AudioNode> = new Set();
7
+ /** @internal upstream connections */
8
+ _inputs: Set<AudioNode> = new Set();
9
+
10
+ readonly numberOfInputs: number;
11
+ readonly numberOfOutputs: number;
12
+ readonly channelCount: number;
13
+
14
+ constructor(numberOfInputs = 1, numberOfOutputs = 1) {
15
+ this.numberOfInputs = numberOfInputs;
16
+ this.numberOfOutputs = numberOfOutputs;
17
+ this.channelCount = 2;
18
+ }
19
+
20
+ connect(destination: AudioNode): AudioNode {
21
+ this._outputs.add(destination);
22
+ destination._inputs.add(this);
23
+ return destination;
24
+ }
25
+
26
+ disconnect(destination?: AudioNode): void {
27
+ if (destination) {
28
+ this._outputs.delete(destination);
29
+ destination._inputs.delete(this);
30
+ } else {
31
+ for (const node of this._outputs) {
32
+ node._inputs.delete(this);
33
+ }
34
+ this._outputs.clear();
35
+ }
36
+ }
37
+ }
@@ -0,0 +1,103 @@
1
+ // AudioParam — holds a value with scheduling support.
2
+ // Phase 1: direct .value + setTargetAtTime (used by Excalibur.js).
3
+ // Reference: https://developer.mozilla.org/en-US/docs/Web/API/AudioParam
4
+
5
+ import GLib from 'gi://GLib?version=2.0';
6
+
7
+ export class AudioParam {
8
+ readonly defaultValue: number;
9
+ readonly minValue: number;
10
+ readonly maxValue: number;
11
+
12
+ /** @internal callback invoked when value changes */
13
+ _onChange: ((value: number) => void) | null = null;
14
+
15
+ private _value: number;
16
+ private _rampTimerId: number | null = null;
17
+
18
+ constructor(defaultValue = 0, minValue = -3.4028235e38, maxValue = 3.4028235e38) {
19
+ this.defaultValue = defaultValue;
20
+ this.minValue = minValue;
21
+ this.maxValue = maxValue;
22
+ this._value = defaultValue;
23
+ }
24
+
25
+ get value(): number {
26
+ return this._value;
27
+ }
28
+
29
+ set value(v: number) {
30
+ this._cancelRamp();
31
+ this._value = Math.max(this.minValue, Math.min(this.maxValue, v));
32
+ this._onChange?.(this._value);
33
+ }
34
+
35
+ setValueAtTime(value: number, _startTime: number): AudioParam {
36
+ // Phase 1: apply immediately (ignore scheduling)
37
+ this.value = value;
38
+ return this;
39
+ }
40
+
41
+ linearRampToValueAtTime(value: number, _endTime: number): AudioParam {
42
+ // Phase 1: apply immediately
43
+ this.value = value;
44
+ return this;
45
+ }
46
+
47
+ exponentialRampToValueAtTime(value: number, _endTime: number): AudioParam {
48
+ // Phase 1: apply immediately
49
+ this.value = value;
50
+ return this;
51
+ }
52
+
53
+ setTargetAtTime(target: number, _startTime: number, timeConstant: number): AudioParam {
54
+ // Exponential approach used by Excalibur for smooth volume transitions.
55
+ // After each timeConstant interval, value moves ~63.2% closer to target.
56
+ this._cancelRamp();
57
+
58
+ if (timeConstant <= 0) {
59
+ this.value = target;
60
+ return this;
61
+ }
62
+
63
+ const stepMs = Math.max(10, Math.round(timeConstant * 100)); // ~10 steps per timeConstant
64
+ this._rampTimerId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, stepMs, () => {
65
+ const diff = target - this._value;
66
+ if (Math.abs(diff) < 0.001) {
67
+ this._value = target;
68
+ this._onChange?.(this._value);
69
+ this._rampTimerId = null;
70
+ return GLib.SOURCE_REMOVE;
71
+ }
72
+ // Exponential approach: move 1 - e^(-dt/tc) closer per step
73
+ const factor = 1 - Math.exp(-stepMs / (timeConstant * 1000));
74
+ this._value += diff * factor;
75
+ this._onChange?.(this._value);
76
+ return GLib.SOURCE_CONTINUE;
77
+ });
78
+
79
+ return this;
80
+ }
81
+
82
+ setValueCurveAtTime(_values: Float32Array, _startTime: number, _duration: number): AudioParam {
83
+ // Phase 1: no-op
84
+ return this;
85
+ }
86
+
87
+ cancelScheduledValues(_startTime: number): AudioParam {
88
+ this._cancelRamp();
89
+ return this;
90
+ }
91
+
92
+ cancelAndHoldAtTime(_cancelTime: number): AudioParam {
93
+ this._cancelRamp();
94
+ return this;
95
+ }
96
+
97
+ private _cancelRamp(): void {
98
+ if (this._rampTimerId !== null) {
99
+ GLib.source_remove(this._rampTimerId);
100
+ this._rampTimerId = null;
101
+ }
102
+ }
103
+ }
@@ -0,0 +1,23 @@
1
+ // GainNode — controls audio volume via an AudioParam.
2
+ // Reference: https://developer.mozilla.org/en-US/docs/Web/API/GainNode
3
+
4
+ import { AudioNode } from './audio-node.js';
5
+ import { AudioParam } from './audio-param.js';
6
+ import type { GstPlayer } from './gst-player.js';
7
+
8
+ export class GainNode extends AudioNode {
9
+ readonly gain: AudioParam;
10
+
11
+ /** @internal active players that need volume updates */
12
+ _activePlayers: Set<GstPlayer> = new Set();
13
+
14
+ constructor() {
15
+ super(1, 1);
16
+ this.gain = new AudioParam(1, 0, 10);
17
+ this.gain._onChange = (value) => {
18
+ for (const player of this._activePlayers) {
19
+ player.setVolume(value);
20
+ }
21
+ };
22
+ }
23
+ }