@aethervtt/shard-platform-web 0.1.0 → 0.2.0

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,59 @@
1
+ import type { AudioAutomation, AudioMixerGraph, AudioScheduledVoice } from '@aethervtt/shard-platform';
2
+ export interface AudioRenderPlan {
3
+ duration: number;
4
+ tailSeconds?: number;
5
+ sampleRate?: number;
6
+ channels?: 1 | 2;
7
+ mixer?: AudioMixerGraph;
8
+ voices: AudioScheduledVoice[];
9
+ automation?: {
10
+ voice: number;
11
+ command: AudioAutomation;
12
+ }[];
13
+ maxOutputBytes?: number;
14
+ decodedBudgetBytes?: number;
15
+ }
16
+ /** Measurements use the floating-point render, before PCM16 quantization/clipping. */
17
+ export declare function measureAudioBuffer(buffer: AudioBuffer): {
18
+ peak: number;
19
+ rms: number;
20
+ clipped: number;
21
+ nonfinite: number;
22
+ sampleRate: number;
23
+ channels: number;
24
+ frames: number;
25
+ duration: number;
26
+ };
27
+ export declare function audioBufferWav(buffer: AudioBuffer): Uint8Array;
28
+ /** Same source/graph implementation as live playback. No sound card, microphone or GPU. */
29
+ export declare function renderAudioPreview(plan: AudioRenderPlan): Promise<{
30
+ buffer: AudioBuffer;
31
+ wav: Uint8Array<ArrayBufferLike>;
32
+ measured: {
33
+ peak: number;
34
+ rms: number;
35
+ clipped: number;
36
+ nonfinite: number;
37
+ sampleRate: number;
38
+ channels: number;
39
+ frames: number;
40
+ duration: number;
41
+ };
42
+ trace: {
43
+ voice: number;
44
+ clip: string;
45
+ requestedAt: number;
46
+ presentation: {
47
+ state: import("@aethervtt/shard-platform").AudioPresentationState;
48
+ scheduledAt: number;
49
+ endedAt: number;
50
+ error: string | null;
51
+ };
52
+ }[];
53
+ capabilities: Readonly<import("@aethervtt/shard-platform").AudioCapabilities>;
54
+ backend: {
55
+ kind: string;
56
+ userAgent: string;
57
+ sampleRate: number;
58
+ };
59
+ }>;
@@ -0,0 +1,142 @@
1
+ import { ShardError } from "@aethervtt/shard-core";
2
+ import { createWebAudioBackend } from "./audio.js";
3
+ function measureAudioBuffer(buffer) {
4
+ let peak = 0, square = 0, clipped = 0, nonfinite = 0;
5
+ for (let channel = 0; channel < buffer.numberOfChannels; channel++) {
6
+ const data = buffer.getChannelData(channel);
7
+ for (let i = 0; i < data.length; i++) {
8
+ const sample = data[i];
9
+ if (!Number.isFinite(sample)) {
10
+ nonfinite++;
11
+ continue;
12
+ }
13
+ peak = Math.max(peak, Math.abs(sample));
14
+ square += sample * sample;
15
+ if (Math.abs(sample) > 1) clipped++;
16
+ }
17
+ }
18
+ return {
19
+ peak,
20
+ rms: Math.sqrt(square / (buffer.length * buffer.numberOfChannels)),
21
+ clipped,
22
+ nonfinite,
23
+ sampleRate: buffer.sampleRate,
24
+ channels: buffer.numberOfChannels,
25
+ frames: buffer.length,
26
+ duration: buffer.duration
27
+ };
28
+ }
29
+ function audioBufferWav(buffer) {
30
+ const channels = buffer.numberOfChannels, size = buffer.length * channels * 2;
31
+ const bytes = new Uint8Array(44 + size), view = new DataView(bytes.buffer);
32
+ const text = (at, value) => {
33
+ for (let i = 0; i < value.length; i++) bytes[at + i] = value.charCodeAt(i);
34
+ };
35
+ text(0, "RIFF");
36
+ view.setUint32(4, 36 + size, true);
37
+ text(8, "WAVE");
38
+ text(12, "fmt ");
39
+ view.setUint32(16, 16, true);
40
+ view.setUint16(20, 1, true);
41
+ view.setUint16(22, channels, true);
42
+ view.setUint32(24, buffer.sampleRate, true);
43
+ view.setUint32(28, buffer.sampleRate * channels * 2, true);
44
+ view.setUint16(32, channels * 2, true);
45
+ view.setUint16(34, 16, true);
46
+ text(36, "data");
47
+ view.setUint32(40, size, true);
48
+ for (let c = 0; c < channels; c++) {
49
+ const data = buffer.getChannelData(c);
50
+ for (let i = 0; i < buffer.length; i++) {
51
+ const value = Math.max(-1, Math.min(1, data[i]));
52
+ view.setInt16(
53
+ 44 + (i * channels + c) * 2,
54
+ Math.round(value * (value < 0 ? 32768 : 32767)),
55
+ true
56
+ );
57
+ }
58
+ }
59
+ return bytes;
60
+ }
61
+ async function renderAudioPreview(plan) {
62
+ const duration = plan.duration + (plan.tailSeconds ?? 0), sampleRate = plan.sampleRate ?? 48e3, channels = plan.channels ?? 2;
63
+ const frames = Math.ceil(duration * sampleRate);
64
+ if (!Number.isFinite(duration) || plan.duration <= 0 || duration > 60 || !Number.isInteger(sampleRate) || sampleRate < 8e3 || sampleRate > 96e3 || ![1, 2].includes(channels) || frames * channels * 6 + 44 > (plan.maxOutputBytes ?? 64 * 1024 * 1024) || plan.voices.length > 4096) {
65
+ throw new ShardError(
66
+ "audio/preview-unavailable",
67
+ "Preview exceeds its duration, rate, voice or output-memory limits"
68
+ );
69
+ }
70
+ if (typeof OfflineAudioContext === "undefined")
71
+ throw new ShardError("audio/preview-unavailable", "This host has no OfflineAudioContext");
72
+ for (const voice of plan.voices)
73
+ if (voice.clip.stream || !voice.clip.bytes.byteLength)
74
+ throw new ShardError(
75
+ "audio/preview-unavailable",
76
+ "Preview requires local encoded bytes for decoded clips"
77
+ );
78
+ const context = new OfflineAudioContext(channels, frames, sampleRate);
79
+ const backend = createWebAudioBackend({
80
+ context,
81
+ fixedListener: true,
82
+ maxVoices: 4096,
83
+ maxHostVoices: 4096,
84
+ decodedBudgetBytes: plan.decodedBudgetBytes ?? 64 * 1024 * 1024
85
+ });
86
+ try {
87
+ const leases = await Promise.all(
88
+ plan.voices.map((voice) => backend.runtime.prepare(voice.clip))
89
+ );
90
+ if (plan.mixer) await backend.runtime.configureMixer(plan.mixer);
91
+ const ids = plan.voices.map(
92
+ (voice) => backend.runtime.play({
93
+ ...voice,
94
+ when: voice.when ?? 0,
95
+ duration: voice.duration ?? (voice.loop ? Math.max(0, plan.duration - (voice.when ?? 0)) : void 0)
96
+ })
97
+ );
98
+ await backend.runtime.flush();
99
+ for (const item of plan.automation ?? []) {
100
+ const id = ids[item.voice];
101
+ if (id === void 0)
102
+ throw new ShardError(
103
+ "audio/preview-unavailable",
104
+ `Automation refers to unknown voice ${item.voice}`
105
+ );
106
+ backend.runtime.automate(id, item.command);
107
+ }
108
+ const trace = ids.map((id, index) => ({
109
+ voice: index,
110
+ clip: plan.voices[index].clip.id,
111
+ requestedAt: plan.voices[index].when ?? 0,
112
+ presentation: { ...backend.runtime.presentation(id) }
113
+ }));
114
+ const buffer = await context.startRendering();
115
+ const measured = measureAudioBuffer(buffer);
116
+ if (measured.nonfinite)
117
+ throw new ShardError(
118
+ "audio/preview-unavailable",
119
+ "Rendered signal contains nonfinite samples"
120
+ );
121
+ for (const lease of leases) lease.release();
122
+ return {
123
+ buffer,
124
+ wav: audioBufferWav(buffer),
125
+ measured,
126
+ trace,
127
+ capabilities: backend.runtime.capabilities,
128
+ backend: {
129
+ kind: "web-audio-offline",
130
+ userAgent: navigator.userAgent,
131
+ sampleRate: context.sampleRate
132
+ }
133
+ };
134
+ } finally {
135
+ backend.dispose?.();
136
+ }
137
+ }
138
+ export {
139
+ audioBufferWav,
140
+ measureAudioBuffer,
141
+ renderAudioPreview
142
+ };
package/dist/audio.d.ts CHANGED
@@ -1,18 +1,14 @@
1
- import type { AudioBackend } from '@aethervtt/shard-platform';
2
- /** Web Audio in a browser or webview: one context, a gain node per bus, a PannerNode per spatial voice. */
1
+ import type { AudioBackend, AudioRuntimeExtension, AudioSessionOptions } from '@aethervtt/shard-platform';
3
2
  export interface WebAudioBackend extends AudioBackend {
4
- readonly context: AudioContext;
5
- /** Everything mixed (before the destination), for meters. */
3
+ readonly context: BaseAudioContext;
6
4
  readonly output: GainNode;
7
- /** A bus's gain node (after its gain), for meters. */
5
+ readonly runtime: AudioRuntimeExtension;
8
6
  busNode(name: string): GainNode;
9
- /** Resumes the context (browsers need a user gesture first). */
10
7
  resume(): Promise<void>;
11
8
  }
12
- /**
13
- * The Web Audio backend. The context starts suspended in most browsers; it resumes on the first
14
- * pointer, key, or touch, and voices asked for before then start in step at that moment.
15
- */
16
- export declare function createWebAudioBackend(options?: {
17
- context?: AudioContext;
9
+ /** fixedListener:true explicitly grants Shard ownership of an external context's listener. */
10
+ export declare function createWebAudioBackend(options?: AudioSessionOptions & {
11
+ context?: BaseAudioContext;
12
+ fixedListener?: boolean;
13
+ maxHostVoices?: number;
18
14
  }): WebAudioBackend;