Skip to content

Commit 7da0315

Browse files
committed
feat(audio-context): DynamicsCompressorNode, ChannelSplitterNode & ChannelMergerNode
1 parent 5f8d9ff commit 7da0315

41 files changed

Lines changed: 3167 additions & 234 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/demo/src/plugin-demos/audio-context.ts

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,101 @@ export function positionSweepTap(args: EventData) {
100100
})();
101101
}
102102

103+
export function splitCompressMergeTap(args: EventData) {
104+
(async () => {
105+
try {
106+
const page = (<any>args.object).page as Page;
107+
const demo = page.bindingContext as DemoSharedAudioContext;
108+
const duration = 2.4;
109+
await demo.initAudio();
110+
const ctx = demo.ctx as AudioContext;
111+
if (!ctx) return;
112+
if (demo.source) demo.stop();
113+
114+
const sampleRate = ctx.sampleRate || 44100;
115+
const length = Math.max(1, Math.floor(sampleRate * duration));
116+
const buffer = ctx.createBuffer({ length, numberOfChannels: 2, sampleRate });
117+
const leftNative = buffer.getChannelData(0);
118+
const rightNative = buffer.getChannelData(1);
119+
if (!leftNative || !rightNative) return;
120+
const left = leftNative;
121+
const right = rightNative;
122+
123+
for (let i = 0; i < length; i++) {
124+
const t = i / sampleRate;
125+
const fadeIn = Math.min(1, i / (sampleRate * 0.02));
126+
const fadeOut = Math.min(1, (length - i) / (sampleRate * 0.05));
127+
const envelope = Math.min(fadeIn, fadeOut);
128+
left[i] = envelope * (Math.sin(2 * Math.PI * 220 * t) * 0.92 + Math.sin(2 * Math.PI * 440 * t) * 0.32);
129+
right[i] = envelope * Math.sin(2 * Math.PI * 660 * t) * 0.24;
130+
}
131+
132+
const src = ctx.createBufferSource();
133+
src.buffer = buffer;
134+
135+
const splitter = ctx.createChannelSplitter({ numberOfOutputs: 2 });
136+
const compressor = ctx.createDynamicsCompressor({
137+
threshold: -36,
138+
knee: 24,
139+
ratio: 12,
140+
attack: 0.003,
141+
release: 0.2,
142+
});
143+
const merger = ctx.createChannelMerger({ numberOfInputs: 2 });
144+
145+
const now = typeof ctx.currentTime === 'number' ? ctx.currentTime : Date.now() / 1000;
146+
compressor.threshold.setValueAtTime(-36, now);
147+
compressor.threshold.linearRampToValueAtTime(-24, now + duration * 0.75);
148+
compressor.knee.setValueAtTime(24, now);
149+
compressor.ratio.setValueAtTime(12, now);
150+
compressor.attack.setValueAtTime(0.003, now);
151+
compressor.release.setValueAtTime(0.2, now);
152+
153+
src.connect(splitter);
154+
splitter.connect(compressor, 0, 0);
155+
compressor.connect(merger, 0, 0);
156+
splitter.connect(merger, 1, 1);
157+
merger.connect(demo.gainNode || ctx.destination);
158+
159+
const cleanup = () => {
160+
try {
161+
src.disconnect && src.disconnect();
162+
} catch (e) {}
163+
try {
164+
splitter.disconnect && splitter.disconnect();
165+
} catch (e) {}
166+
try {
167+
compressor.disconnect && compressor.disconnect();
168+
} catch (e) {}
169+
try {
170+
merger.disconnect && merger.disconnect();
171+
} catch (e) {}
172+
};
173+
174+
const ok = await demo.startSourceSafe(src);
175+
if (!ok) {
176+
cleanup();
177+
console.warn('splitCompressMergeTap: source failed to start');
178+
return;
179+
}
180+
181+
demo.source = src;
182+
demo.isPlaying = true;
183+
src.onended = () => {
184+
cleanup();
185+
demo.source = null;
186+
demo.isPlaying = false;
187+
demo.stopVisualizer();
188+
};
189+
190+
if (demo.analyser && demo.visualizerCanvas && !demo.rafId) demo.startVisualizer();
191+
} catch (error) {
192+
console.warn('splitCompressMergeTap failed:', error);
193+
}
194+
return;
195+
})();
196+
}
197+
103198
export function testPanLeft(args: EventData) {
104199
(async () => {
105200
const page = (<any>args.object).page as Page;

apps/demo/src/plugin-demos/audio-context.xml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
<Button text="Stop" tap="stopTap" class="btn btn-secondary" />
1010
<Button text="Pan Sweep" tap="panSweepTap" class="btn btn-secondary" />
1111
<Button text="Position Sweep" tap="positionSweepTap" class="btn btn-secondary" />
12+
<Button text="Split + Compress + Merge" tap="splitCompressMergeTap" class="btn btn-secondary" />
1213
<Button text="Pan Left" tap="testPanLeft" class="btn btn-secondary" />
1314
<Button text="Pan Right" tap="testPanRight" class="btn btn-secondary" />
1415
</StackLayout>
@@ -20,7 +21,7 @@
2021
</StackLayout>
2122

2223
<StackLayout row="3" padding="8">
23-
<Label text="Realtime graphic EQ is disabled, visualizer only." class="muted" />
24+
<Label text="Try Split + Compress + Merge to exercise ChannelSplitterNode, DynamicsCompressorNode, and ChannelMergerNode." class="muted" />
2425
</StackLayout>
2526
</GridLayout>
2627
</Page>

packages/audio-context/common.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,11 @@ export interface AudioContextOptions {
106106
latencyHint?: LatencyHint;
107107
}
108108

109+
export interface AudioBufferCopyOptions {
110+
startInChannel?: number;
111+
byteOffset?: number;
112+
}
113+
109114
const LATENCY_HINT_SECONDS = {
110115
interactive: 0.005,
111116
balanced: 0.012,
@@ -201,6 +206,19 @@ export interface IIRFilterOptions {
201206
export interface ConvolverOptions {
202207
disableNormalization?: boolean;
203208
}
209+
export interface DynamicsCompressorOptions {
210+
threshold?: number;
211+
knee?: number;
212+
ratio?: number;
213+
attack?: number;
214+
release?: number;
215+
}
216+
export interface ChannelSplitterOptions {
217+
numberOfOutputs?: number;
218+
}
219+
export interface ChannelMergerOptions {
220+
numberOfInputs?: number;
221+
}
204222
export interface PeriodicWaveOptions {
205223
real?: Float32Array | number[];
206224
imag?: Float32Array | number[];

packages/audio-context/index.android.ts

Lines changed: 165 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,21 @@
11
import { Utils } from '@nativescript/core';
22
import {
33
AnalyserOptions,
4+
AudioBufferCopyOptions,
45
AudioContextOptions,
56
AudioNodeBase,
67
AudioParamBase,
78
AudioParamHooks,
89
BaseAudioContext,
10+
ChannelMergerOptions,
11+
ChannelSplitterOptions,
912
ConstantSourceOptions,
1013
ConvolverOptions,
1114
DelayOptions,
1215
distanceModelFromNumber,
1316
distanceModelToNumber,
1417
DistanceModelType,
18+
DynamicsCompressorOptions,
1519
IIRFilterOptions,
1620
MediaElementLike,
1721
panningModelFromNumber,
@@ -39,6 +43,40 @@ import {
3943

4044
type NativeBaseAudioContext = BaseAudioContext & { native: org.nativescript.audiocontext.AudioContextInstance };
4145

46+
function normalizeCopyByteOffset(view: ArrayBufferView, byteOffset?: number): number {
47+
let offset = typeof byteOffset === 'number' && Number.isFinite(byteOffset) ? byteOffset : view.byteOffset;
48+
offset = Math.max(0, offset | 0);
49+
if (offset > view.buffer.byteLength) offset = view.buffer.byteLength;
50+
const align = ((view as any).BYTES_PER_ELEMENT as number) || 1;
51+
return offset - (offset % align);
52+
}
53+
54+
function resolveAudioBufferCopyOptions(view: ArrayBufferView, startOrOptions?: number | AudioBufferCopyOptions): { startInChannel: number; byteOffset: number } {
55+
let startInChannel = 0;
56+
let byteOffset: number | undefined;
57+
if (typeof startOrOptions === 'number') {
58+
startInChannel = startOrOptions;
59+
} else if (startOrOptions) {
60+
if (typeof startOrOptions.startInChannel === 'number') startInChannel = startOrOptions.startInChannel;
61+
if (typeof startOrOptions.byteOffset === 'number') byteOffset = startOrOptions.byteOffset;
62+
}
63+
if (!Number.isFinite(startInChannel)) startInChannel = 0;
64+
startInChannel = Math.max(0, startInChannel | 0);
65+
return {
66+
startInChannel,
67+
byteOffset: normalizeCopyByteOffset(view, byteOffset),
68+
};
69+
}
70+
71+
function viewAtByteOffset(view: Float32Array, byteOffset: number): Float32Array {
72+
if (byteOffset === view.byteOffset) return view;
73+
if (byteOffset >= view.buffer.byteLength) return new Float32Array(0);
74+
const availableBytes = view.buffer.byteLength - byteOffset;
75+
const length = Math.floor(availableBytes / Float32Array.BYTES_PER_ELEMENT);
76+
if (length <= 0) return new Float32Array(0);
77+
return new Float32Array(view.buffer, byteOffset, length);
78+
}
79+
4280
function makeAndroidHooks(native: org.nativescript.audiocontext.AudioParam): AudioParamHooks {
4381
return {
4482
nativeSet(v) {
@@ -175,19 +213,25 @@ export class AudioBuffer {
175213
}
176214

177215
getChannelData(channel: number): Float32Array | null {
178-
const data = this.native.getChannelData(channel);
216+
const data = this.native.getChannelDataRaw(channel);
179217
if (!data) return null;
180218
return new Float32Array((ArrayBuffer as any).from(data));
181219
}
182220

183-
copyFromChannel(dest: Float32Array, channel: number, startInChannel: number = 0) {
221+
copyFromChannel(dest: Float32Array, channel: number, startInChannel: number | AudioBufferCopyOptions = 0) {
184222
if (!dest) return;
185-
this.native.copyFromChannel(dest as any, channel, startInChannel ?? 0);
223+
const options = resolveAudioBufferCopyOptions(dest, startInChannel);
224+
const target = viewAtByteOffset(dest, options.byteOffset);
225+
if (target.length === 0) return;
226+
this.native.copyFromChannel(target as any, channel, options.startInChannel);
186227
}
187228

188-
copyToChannel(source: Float32Array, channel: number, startInChannel: number = 0) {
229+
copyToChannel(source: Float32Array, channel: number, startInChannel: number | AudioBufferCopyOptions = 0) {
189230
if (!source) return;
190-
this.native.copyToChannel(source, channel, startInChannel ?? 0);
231+
const options = resolveAudioBufferCopyOptions(source, startInChannel);
232+
const input = viewAtByteOffset(source, options.byteOffset);
233+
if (input.length === 0) return;
234+
this.native.copyToChannel(input, channel, options.startInChannel);
191235
}
192236
}
193237

@@ -395,7 +439,7 @@ export class AudioScheduledSourceNode extends AudioNode {
395439
if (this._nativeEndedWired) return;
396440
this._nativeEndedWired = true;
397441
try {
398-
this._javaEndedListener = new (org.nativescript.audiocontext.AudioContext as any).EndedListener({
442+
this._javaEndedListener = new org.nativescript.audiocontext.AudioContext.EndedListener({
399443
onEnded: () => this._fireEnded(),
400444
});
401445
this.native.addEndedListener(this._javaEndedListener);
@@ -593,6 +637,15 @@ export class OfflineAudioContext extends BaseAudioContext {
593637
createConvolver(options?: ConvolverOptions) {
594638
return new ConvolverNode(this as never, options ?? {});
595639
}
640+
createDynamicsCompressor(options?: DynamicsCompressorOptions) {
641+
return new DynamicsCompressorNode(this as never, options ?? {});
642+
}
643+
createChannelSplitter(options?: ChannelSplitterOptions) {
644+
return new ChannelSplitterNode(this as never, options ?? {});
645+
}
646+
createChannelMerger(options?: ChannelMergerOptions) {
647+
return new ChannelMergerNode(this as never, options ?? {});
648+
}
596649
createPeriodicWave(real: Float32Array | number[], imag: Float32Array | number[], options?: { disableNormalization?: boolean }) {
597650
return new PeriodicWave(this as never, { real, imag, disableNormalization: options?.disableNormalization });
598651
}
@@ -750,6 +803,15 @@ export class AudioContext extends BaseAudioContext {
750803
createConvolver(options?: ConvolverOptions) {
751804
return new ConvolverNode(this as never, options ?? {});
752805
}
806+
createDynamicsCompressor(options?: DynamicsCompressorOptions) {
807+
return new DynamicsCompressorNode(this as never, options ?? {});
808+
}
809+
createChannelSplitter(options?: ChannelSplitterOptions) {
810+
return new ChannelSplitterNode(this as never, options ?? {});
811+
}
812+
createChannelMerger(options?: ChannelMergerOptions) {
813+
return new ChannelMergerNode(this as never, options ?? {});
814+
}
753815
createPeriodicWave(real: Float32Array | number[], imag: Float32Array | number[], options?: { disableNormalization?: boolean }) {
754816
return new PeriodicWave(this as never, { real, imag, disableNormalization: options?.disableNormalization });
755817
}
@@ -1143,6 +1205,103 @@ export class ConvolverNode extends AudioNode {
11431205
}
11441206
}
11451207

1208+
export class DynamicsCompressorNode extends AudioNode {
1209+
[native_]: org.nativescript.audiocontext.DynamicsCompressorNode;
1210+
private _threshold: AudioParam | null = null;
1211+
private _knee: AudioParam | null = null;
1212+
private _ratio: AudioParam | null = null;
1213+
private _attack: AudioParam | null = null;
1214+
private _release: AudioParam | null = null;
1215+
private _reduction: AudioParam | null = null;
1216+
1217+
constructor(context: NativeBaseAudioContext, options: DynamicsCompressorOptions = {}) {
1218+
super(context);
1219+
this[native_] = AudioContext.getInstance().createDynamicsCompressor(context.native);
1220+
1221+
if (typeof options.threshold === 'number') this.threshold.value = options.threshold;
1222+
if (typeof options.knee === 'number') this.knee.value = options.knee;
1223+
if (typeof options.ratio === 'number') this.ratio.value = options.ratio;
1224+
if (typeof options.attack === 'number') this.attack.value = options.attack;
1225+
if (typeof options.release === 'number') this.release.value = options.release;
1226+
}
1227+
1228+
get native() {
1229+
return this[native_];
1230+
}
1231+
1232+
get threshold() {
1233+
return this._threshold || (this._threshold = new AudioParam(nativeCtor_, this.native.getThreshold()));
1234+
}
1235+
1236+
get knee() {
1237+
return this._knee || (this._knee = new AudioParam(nativeCtor_, this.native.getKnee()));
1238+
}
1239+
1240+
get ratio() {
1241+
return this._ratio || (this._ratio = new AudioParam(nativeCtor_, this.native.getRatio()));
1242+
}
1243+
1244+
get attack() {
1245+
return this._attack || (this._attack = new AudioParam(nativeCtor_, this.native.getAttack()));
1246+
}
1247+
1248+
get release() {
1249+
return this._release || (this._release = new AudioParam(nativeCtor_, this.native.getRelease()));
1250+
}
1251+
1252+
get reduction() {
1253+
return this._reduction || (this._reduction = new AudioParam(nativeCtor_, this.native.getReduction()));
1254+
}
1255+
}
1256+
1257+
export class ChannelSplitterNode extends AudioNode {
1258+
[native_]: org.nativescript.audiocontext.ChannelSplitterNode;
1259+
private _numberOfOutputs: number;
1260+
1261+
constructor(context: NativeBaseAudioContext, options: ChannelSplitterOptions = {}) {
1262+
super(context);
1263+
this._numberOfOutputs = Math.max(1, options.numberOfOutputs ?? 6);
1264+
this[native_] = AudioContext.getInstance().createChannelSplitter(context.native, this._numberOfOutputs);
1265+
}
1266+
1267+
get native() {
1268+
return this[native_];
1269+
}
1270+
1271+
get numberOfOutputs() {
1272+
const native = this.native as org.nativescript.audiocontext.ChannelSplitterNode;
1273+
if (native && typeof native.getNumberOfOutputs === 'function') {
1274+
const value = native.getNumberOfOutputs();
1275+
if (typeof value === 'number' && Number.isFinite(value)) return value;
1276+
}
1277+
return this._numberOfOutputs;
1278+
}
1279+
}
1280+
1281+
export class ChannelMergerNode extends AudioNode {
1282+
[native_]: org.nativescript.audiocontext.ChannelMergerNode;
1283+
private _numberOfInputs: number;
1284+
1285+
constructor(context: NativeBaseAudioContext, options: ChannelMergerOptions = {}) {
1286+
super(context);
1287+
this._numberOfInputs = Math.max(1, options.numberOfInputs ?? 6);
1288+
this[native_] = AudioContext.getInstance().createChannelMerger(context.native, this._numberOfInputs);
1289+
}
1290+
1291+
get native() {
1292+
return this[native_];
1293+
}
1294+
1295+
get numberOfInputs() {
1296+
const native = this.native as org.nativescript.audiocontext.ChannelMergerNode;
1297+
if (native && typeof native.getNumberOfInputs === 'function') {
1298+
const value = native.getNumberOfInputs();
1299+
if (typeof value === 'number' && Number.isFinite(value)) return value;
1300+
}
1301+
return this._numberOfInputs;
1302+
}
1303+
}
1304+
11461305
export class PeriodicWave {
11471306
[native_]: org.nativescript.audiocontext.PeriodicWave;
11481307
private real: Float32Array;

0 commit comments

Comments
 (0)