lecodes-sdk 0.20.0 → 0.20.2
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/dist/global.d.ts +31 -0
- package/dist/inject.js +361 -260
- package/dist/types/audio/Bus.d.ts +45 -0
- package/dist/types/audio/Sound.d.ts +28 -0
- package/dist/types/audio/Voice.d.ts +27 -0
- package/dist/types/audio/audio.d.ts +83 -0
- package/dist/types/audio/support.d.ts +1 -0
- package/dist/types/gl/AudioSource.d.ts +60 -0
- package/dist/types/gl/AudioZone.d.ts +32 -0
- package/dist/types/gl/DecalSet.d.ts +103 -0
- package/dist/types/gl/Geometry.d.ts +5 -0
- package/dist/types/gl/Light.d.ts +7 -0
- package/dist/types/gl/Material.d.ts +86 -2
- package/dist/types/gl/Mesh.d.ts +11 -0
- package/dist/types/gl/Scene.d.ts +23 -0
- package/dist/types/gl/SceneAudio.d.ts +11 -0
- package/dist/types/gl/Texture.d.ts +29 -1
- package/dist/types/gl/animation/AnimationClip.d.ts +25 -12
- package/dist/types/gl/animation/core.d.ts +15 -10
- package/dist/types/gl/state.d.ts +0 -1
- package/dist/types/inject.d.ts +10 -0
- package/dist/types/runtime/input.d.ts +11 -0
- package/dist/types/ui/UIImage.d.ts +15 -5
- package/dist/types.json +1 -1
- package/package.json +1 -1
- package/prompts/dist/2d-game.md +408 -197
- package/prompts/dist/3d-app.md +491 -166
- package/prompts/dist/ar-app.md +373 -163
- package/prompts/dist/design.md +83 -87
- package/prompts/dist/ui-app.md +325 -136
- package/src/audio/Bus.ts +102 -0
- package/src/audio/Sound.ts +96 -0
- package/src/audio/Voice.ts +102 -0
- package/src/audio/audio.ts +161 -0
- package/src/audio/support.ts +6 -0
- package/src/bridges.d.ts +1481 -1352
- package/src/compile/compileProject.ts +30 -15
- package/src/compile/index.ts +3 -0
- package/src/core/Aspect.ts +33 -8
- package/src/g2/Scene2D.ts +7 -0
- package/src/gl/AudioSource.ts +113 -0
- package/src/gl/AudioZone.ts +75 -0
- package/src/gl/CameraPlace.ts +52 -52
- package/src/gl/DecalSet.ts +233 -0
- package/src/gl/Geometry.ts +5 -0
- package/src/gl/Light.ts +16 -0
- package/src/gl/Lightmap.ts +3 -2
- package/src/gl/Material.ts +152 -4
- package/src/gl/Mesh.ts +20 -1
- package/src/gl/Ragdoll.ts +270 -270
- package/src/gl/Scene.ts +41 -7
- package/src/gl/SceneAudio.ts +26 -0
- package/src/gl/Texture.ts +43 -3
- package/src/gl/Trigger.ts +45 -45
- package/src/gl/Vehicle.ts +5 -5
- package/src/gl/animation/AnimationClip.ts +43 -20
- package/src/gl/animation/Animator.ts +4 -3
- package/src/gl/animation/core.ts +20 -15
- package/src/gl/scenarios.ts +291 -291
- package/src/gl/state.ts +1 -1
- package/src/inject.ts +12 -0
- package/src/runtime/input.ts +6 -1
- package/src/scene/gizmos.ts +148 -148
- package/src/ui/UIImage.ts +21 -7
package/src/audio/Bus.ts
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// Mixer buses (docs/audio-plan.md §2): every voice plays through one. `master`, `sfx`, `music`,
|
|
2
|
+
// `ui`, `voice` exist from the start; `audio.bus('footsteps')` creates one under master. A bus
|
|
3
|
+
// carries an insert chain — low-pass → echo → reverb — set as plain values; the engine smooths
|
|
4
|
+
// every change, so `bus.reverb = 'cave'` at a doorway does not click.
|
|
5
|
+
//
|
|
6
|
+
// audio.bus('sfx').volume = 0.7
|
|
7
|
+
// audio.bus('sfx').reverb = 'hall'
|
|
8
|
+
// audio.bus('sfx').reverb = { roomSize: 0.8, damping: 0.3, mix: 0.25 }
|
|
9
|
+
// audio.bus('sfx').echo = { delay: 0.35, decay: 0.4, mix: 0.3 }
|
|
10
|
+
// audio.bus('sfx').lowpass = 1200 // Hz; null = off
|
|
11
|
+
// audio.master.muted = true
|
|
12
|
+
|
|
13
|
+
import { audioSupported } from "./support"
|
|
14
|
+
|
|
15
|
+
export type ReverbPreset = "room" | "hall" | "cave" | "arena" | "bathroom" | "outdoor"
|
|
16
|
+
|
|
17
|
+
export type ReverbParams = {
|
|
18
|
+
/** 0 (a closet) … 1 (a cathedral). */
|
|
19
|
+
roomSize?: number
|
|
20
|
+
/** High-frequency loss per reflection, 0 … 1. */
|
|
21
|
+
damping?: number
|
|
22
|
+
/** Stereo width of the tail, 0 … 1. */
|
|
23
|
+
width?: number
|
|
24
|
+
/** Wet amount, 0 … 1. */
|
|
25
|
+
mix?: number
|
|
26
|
+
/** Seconds before the tail starts (a big hall: 0.02 … 0.05). */
|
|
27
|
+
preDelay?: number
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export type EchoParams = {
|
|
31
|
+
/** Seconds between repeats (up to 2). */
|
|
32
|
+
delay?: number
|
|
33
|
+
/** Feedback, 0 … 0.95 — how many repeats survive. */
|
|
34
|
+
decay?: number
|
|
35
|
+
/** Wet amount, 0 … 1. */
|
|
36
|
+
mix?: number
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export const REVERB_PRESETS: Record<ReverbPreset, Required<ReverbParams>> = {
|
|
40
|
+
room: { roomSize: 0.45, damping: 0.6, width: 0.8, mix: 0.18, preDelay: 0.005 },
|
|
41
|
+
bathroom: { roomSize: 0.55, damping: 0.15, width: 0.6, mix: 0.35, preDelay: 0.002 },
|
|
42
|
+
hall: { roomSize: 0.82, damping: 0.4, width: 1.0, mix: 0.3, preDelay: 0.025 },
|
|
43
|
+
cave: { roomSize: 0.9, damping: 0.25, width: 1.0, mix: 0.45, preDelay: 0.04 },
|
|
44
|
+
arena: { roomSize: 0.95, damping: 0.5, width: 1.0, mix: 0.3, preDelay: 0.06 },
|
|
45
|
+
outdoor: { roomSize: 0.3, damping: 0.8, width: 1.0, mix: 0.06, preDelay: 0.0 },
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** @internal the engine's 5-float reverb block from a preset name or params. */
|
|
49
|
+
export function reverbBlock(v: ReverbPreset | ReverbParams): Float32Array {
|
|
50
|
+
const p = typeof v === "string" ? REVERB_PRESETS[v] ?? REVERB_PRESETS.room : { ...REVERB_PRESETS.room, ...v }
|
|
51
|
+
return new Float32Array([ p.roomSize, p.damping, p.width, p.mix, p.preDelay ])
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const FX_REVERB = 1, FX_ECHO = 2, FX_LOWPASS = 3
|
|
55
|
+
|
|
56
|
+
export class Bus {
|
|
57
|
+
readonly name: string
|
|
58
|
+
/** @internal engine bus id (-1 when the host has no audio). */
|
|
59
|
+
readonly _id: number
|
|
60
|
+
private _volume = 1
|
|
61
|
+
private _muted = false
|
|
62
|
+
private _reverb: ReverbPreset | ReverbParams | null = null
|
|
63
|
+
private _echo: EchoParams | null = null
|
|
64
|
+
private _lowpass: number | null = null
|
|
65
|
+
|
|
66
|
+
/** @internal */
|
|
67
|
+
constructor(name: string, id: number) {
|
|
68
|
+
this.name = name
|
|
69
|
+
this._id = id
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
private get _live(): boolean { return audioSupported && this._id >= 0 }
|
|
73
|
+
|
|
74
|
+
get volume(): number { return this._volume }
|
|
75
|
+
set volume(v: number) { this._volume = v; if (this._live) _creatorAudio.setBusVolume(this._id, v) }
|
|
76
|
+
get muted(): boolean { return this._muted }
|
|
77
|
+
set muted(v: boolean) { this._muted = v; if (this._live) _creatorAudio.setBusMuted(this._id, v) }
|
|
78
|
+
|
|
79
|
+
/** A preset name, explicit params, or null (off). */
|
|
80
|
+
get reverb(): ReverbPreset | ReverbParams | null { return this._reverb }
|
|
81
|
+
set reverb(v: ReverbPreset | ReverbParams | null) {
|
|
82
|
+
this._reverb = v
|
|
83
|
+
if (this._live) _creatorAudio.setBusEffect(this._id, FX_REVERB, v === null ? new Float32Array(0) : reverbBlock(v))
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
get echo(): EchoParams | null { return this._echo }
|
|
87
|
+
set echo(v: EchoParams | null) {
|
|
88
|
+
this._echo = v
|
|
89
|
+
if (!this._live) return
|
|
90
|
+
_creatorAudio.setBusEffect(this._id, FX_ECHO, v === null ? new Float32Array(0) : new Float32Array([ v.delay ?? 0.3, v.decay ?? 0.4, v.mix ?? 0.3 ]))
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Low-pass cutoff in Hz (20 … 20000), null = off. */
|
|
94
|
+
get lowpass(): number | null { return this._lowpass }
|
|
95
|
+
set lowpass(v: number | null) {
|
|
96
|
+
this._lowpass = v
|
|
97
|
+
if (this._live) _creatorAudio.setBusEffect(this._id, FX_LOWPASS, v === null ? new Float32Array(0) : new Float32Array([ v ]))
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Stop every voice on this bus (fade in seconds). */
|
|
101
|
+
stopAll(fade = 0): void { if (this._live) _creatorAudio.stopBus(this._id, fade) }
|
|
102
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// A decoded clip (docs/audio-plan.md §2): the whole file fetched through the SDK's `fetch` (asset /
|
|
2
|
+
// http / `id:` local) and decoded up front to PCM inside the engine, so playing it later costs
|
|
3
|
+
// nothing — footsteps, shots, impacts, UI clicks. Long streams (music, voice-over) stay with
|
|
4
|
+
// `AudioPlayer`, the platform-native player.
|
|
5
|
+
//
|
|
6
|
+
// const shot = await Sound.load(asset('./sfx/shot.ogg'))
|
|
7
|
+
// const steps = await Sound.load([asset('./sfx/step1.ogg'), asset('./sfx/step2.ogg')]) // variants
|
|
8
|
+
// audio.play(shot) // 2D
|
|
9
|
+
// gun.audio.play(shot) // 3D, through an AudioSource aspect
|
|
10
|
+
//
|
|
11
|
+
// A Sound is APP-scoped (the menu and the level share one `shot`); the engine drops every clip
|
|
12
|
+
// when `_creatorUtils.run()` swaps worlds. Without host support `load` resolves to a silent clip.
|
|
13
|
+
|
|
14
|
+
import { fetch } from "../runtime/fetch"
|
|
15
|
+
import { audioSupported } from "./support"
|
|
16
|
+
|
|
17
|
+
export type SoundOptions = {
|
|
18
|
+
/** Keep two channels (2D playback of stereo material — music beds, ambiences). Default: the clip
|
|
19
|
+
* is decoded MONO, which is what 3D spatialization needs and what SFX are anyway. */
|
|
20
|
+
stereo?: boolean
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export class Sound {
|
|
24
|
+
/** The urls this clip was loaded from (one per variant). */
|
|
25
|
+
readonly urls: readonly string[]
|
|
26
|
+
/** @internal engine clip ids per variant (0 = silent). */
|
|
27
|
+
readonly _clips: number[]
|
|
28
|
+
private readonly _durations: number[]
|
|
29
|
+
private _channels = 1
|
|
30
|
+
private _disposed = false
|
|
31
|
+
private _last = -1
|
|
32
|
+
|
|
33
|
+
private constructor(urls: string[], clips: number[], durations: number[], channels: number) {
|
|
34
|
+
this.urls = urls
|
|
35
|
+
this._clips = clips
|
|
36
|
+
this._durations = durations
|
|
37
|
+
this._channels = channels
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Fetch + decode. An array = variants: `play` picks a random one (never the same twice in a row
|
|
41
|
+
* when there are 3 or more). Rejects with the failing url when a file cannot be decoded. */
|
|
42
|
+
static async load(src: string | string[], options: SoundOptions = {}): Promise<Sound> {
|
|
43
|
+
const urls = Array.isArray(src) ? src.slice() : [ src ]
|
|
44
|
+
if (urls.length === 0) throw new Error("Sound.load: no urls")
|
|
45
|
+
if (!audioSupported) return new Sound(urls, urls.map(() => 0), urls.map(() => 0), options.stereo ? 2 : 1)
|
|
46
|
+
const clips: number[] = []
|
|
47
|
+
const durations: number[] = []
|
|
48
|
+
let channels = 1
|
|
49
|
+
for (const url of urls) {
|
|
50
|
+
const resp = await fetch(url, { useOnce: true })
|
|
51
|
+
const systemId = (resp as unknown as { _id: number })._id
|
|
52
|
+
try {
|
|
53
|
+
const [ clipId, duration, ch ] = await new Promise<[number, number, number]>((resolve, reject) => {
|
|
54
|
+
_creatorAudio.loadClip(systemId, !!options.stereo,
|
|
55
|
+
(id, d, c) => resolve([ id, d, c ]),
|
|
56
|
+
(message) => reject(new Error(`Sound.load: ${url}: ${message}`)))
|
|
57
|
+
})
|
|
58
|
+
clips.push(clipId)
|
|
59
|
+
durations.push(duration)
|
|
60
|
+
channels = ch
|
|
61
|
+
} finally {
|
|
62
|
+
resp.dispose()
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return new Sound(urls, clips, durations, channels)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Seconds (the first variant's). 0 for a silent clip. */
|
|
69
|
+
get duration(): number { return this._durations[0] ?? 0 }
|
|
70
|
+
/** Decoded channel count: 1, or 2 with `{ stereo: true }`. */
|
|
71
|
+
get channels(): number { return this._channels }
|
|
72
|
+
/** How many variants this clip carries. */
|
|
73
|
+
get variants(): number { return this._clips.length }
|
|
74
|
+
/** True when the engine has this clip (false on hosts without audio, or after dispose). */
|
|
75
|
+
get ready(): boolean { return !this._disposed && this._clips[0] > 0 }
|
|
76
|
+
get disposed(): boolean { return this._disposed }
|
|
77
|
+
|
|
78
|
+
/** @internal the clip id to play now (random variant, no immediate repeats). */
|
|
79
|
+
_pick(): number {
|
|
80
|
+
const n = this._clips.length
|
|
81
|
+
if (n <= 1) return this._clips[0] ?? 0
|
|
82
|
+
let i = Math.floor(Math.random() * n)
|
|
83
|
+
if (n >= 3 && i === this._last) i = (i + 1 + Math.floor(Math.random() * (n - 1))) % n
|
|
84
|
+
this._last = i
|
|
85
|
+
return this._clips[i]
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Free the engine's PCM. Voices playing it stop at once. Idempotent. */
|
|
89
|
+
dispose(): void {
|
|
90
|
+
if (this._disposed) return
|
|
91
|
+
this._disposed = true
|
|
92
|
+
if (!audioSupported) return
|
|
93
|
+
for (const id of this._clips) if (id) _creatorAudio.releaseClip(id)
|
|
94
|
+
this._clips.fill(0)
|
|
95
|
+
}
|
|
96
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// The handle `play` returns (docs/audio-plan.md §2): a token into the engine's voice pool. Most
|
|
2
|
+
// callers drop it (fire and forget); a loop keeps it to change pitch / volume and to stop it. A
|
|
3
|
+
// voice that ended (naturally, by stop, or stolen by a louder one) reads `playing === false`; every
|
|
4
|
+
// setter on it is a silent no-op — the pool slot may already belong to a newer sound, and the
|
|
5
|
+
// token's generation bits make that impossible to address.
|
|
6
|
+
//
|
|
7
|
+
// const hum = car.audio.play(engineLoop, { loop: true })
|
|
8
|
+
// hum.pitch = 0.8 + rpm * 0.6
|
|
9
|
+
// hum.stop({ fade: 0.2 })
|
|
10
|
+
// shot.addEventListener('ended', () => …) // registered with the engine only when asked for
|
|
11
|
+
|
|
12
|
+
import { Emitter } from "../core/events"
|
|
13
|
+
import { audioSupported } from "./support"
|
|
14
|
+
|
|
15
|
+
export type VoiceEvents = {
|
|
16
|
+
/** The voice is over: the clip ended, `stop` completed, or the pool reused the slot. */
|
|
17
|
+
ended: () => void
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const watched = new Map<number, Voice>()
|
|
21
|
+
let endedInstalled = false
|
|
22
|
+
|
|
23
|
+
function installEnded(): void {
|
|
24
|
+
if (endedInstalled || !audioSupported) return
|
|
25
|
+
endedInstalled = true
|
|
26
|
+
_creatorAudio.setOnEnded((tokens) => {
|
|
27
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
28
|
+
const v = watched.get(tokens[i])
|
|
29
|
+
if (!v) continue
|
|
30
|
+
watched.delete(tokens[i])
|
|
31
|
+
v._ended()
|
|
32
|
+
}
|
|
33
|
+
})
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export class Voice extends Emitter<VoiceEvents> {
|
|
37
|
+
/** @internal engine token, 0 = inert. */
|
|
38
|
+
readonly _tok: number
|
|
39
|
+
private _volume: number
|
|
40
|
+
private _pitch: number
|
|
41
|
+
private _pan: number
|
|
42
|
+
private _over = false
|
|
43
|
+
/** @internal a transient source (playAt) to destroy when the voice ends. */
|
|
44
|
+
_ownSource = 0
|
|
45
|
+
|
|
46
|
+
/** @internal */
|
|
47
|
+
constructor(token: number, volume: number, pitch: number, pan: number) {
|
|
48
|
+
super()
|
|
49
|
+
this._tok = token
|
|
50
|
+
this._volume = volume
|
|
51
|
+
this._pitch = pitch
|
|
52
|
+
this._pan = pan
|
|
53
|
+
if (token === 0) this._over = true
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** @internal watch the token so `ended` fires (also used by playAt's source cleanup). */
|
|
57
|
+
_watch(): void {
|
|
58
|
+
if (this._over || this._tok === 0) return
|
|
59
|
+
installEnded()
|
|
60
|
+
watched.set(this._tok, this)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
addEventListener<K extends keyof VoiceEvents>(channel: K, callback: VoiceEvents[K]): void {
|
|
64
|
+
super.addEventListener(channel, callback)
|
|
65
|
+
if (channel === "ended") {
|
|
66
|
+
if (this._over) callback()
|
|
67
|
+
else this._watch()
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** @internal */
|
|
72
|
+
_ended(): void {
|
|
73
|
+
if (this._over) return
|
|
74
|
+
this._over = true
|
|
75
|
+
if (this._ownSource && audioSupported) { _creatorAudio.destroySource(this._ownSource); this._ownSource = 0 }
|
|
76
|
+
this.dispatch("ended")
|
|
77
|
+
this.clearListeners()
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** True while the engine plays this voice. */
|
|
81
|
+
get playing(): boolean {
|
|
82
|
+
if (this._over) return false
|
|
83
|
+
return audioSupported && _creatorAudio.voicePlaying(this._tok)
|
|
84
|
+
}
|
|
85
|
+
/** Seconds into the clip. */
|
|
86
|
+
get time(): number { return this._over || !audioSupported ? 0 : _creatorAudio.voiceTime(this._tok) }
|
|
87
|
+
|
|
88
|
+
get volume(): number { return this._volume }
|
|
89
|
+
set volume(v: number) { this._volume = v; if (!this._over && audioSupported) _creatorAudio.setVoiceVolume(this._tok, v) }
|
|
90
|
+
get pitch(): number { return this._pitch }
|
|
91
|
+
set pitch(v: number) { this._pitch = v; if (!this._over && audioSupported) _creatorAudio.setVoicePitch(this._tok, v) }
|
|
92
|
+
/** 2D voices only: -1 left … 1 right. */
|
|
93
|
+
get pan(): number { return this._pan }
|
|
94
|
+
set pan(v: number) { this._pan = v; if (!this._over && audioSupported) _creatorAudio.setVoicePan(this._tok, v) }
|
|
95
|
+
|
|
96
|
+
/** Stop now, or fade out over `fade` seconds. `ended` fires from the engine afterwards. */
|
|
97
|
+
stop(options: { fade?: number } = {}): void {
|
|
98
|
+
if (this._over || !audioSupported) return
|
|
99
|
+
_creatorAudio.stopVoice(this._tok, options.fade ?? 0)
|
|
100
|
+
if (this._ownSource || this.hasListeners("ended")) this._watch()
|
|
101
|
+
}
|
|
102
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
// The `audio` global (docs/audio-plan.md §2): 2D playback, one-shots at a world point, buses.
|
|
2
|
+
// 3D playback from a node goes through the AudioSource aspect (gl/AudioSource.ts), which calls
|
|
3
|
+
// back into `_play` here with its source id; the listener lives on `scene.audio`.
|
|
4
|
+
//
|
|
5
|
+
// audio.play(click, { bus: 'ui' })
|
|
6
|
+
// audio.playAt(impact, hit.point, { maxDistance: 25 })
|
|
7
|
+
// audio.bus('sfx').reverb = 'cave'
|
|
8
|
+
// audio.stopAll(0.2)
|
|
9
|
+
|
|
10
|
+
import { cx, cy, cz, type Vec3Like } from "../math/vec"
|
|
11
|
+
import { Bus } from "./Bus"
|
|
12
|
+
import type { Sound } from "./Sound"
|
|
13
|
+
import { audioSupported } from "./support"
|
|
14
|
+
import { Voice } from "./Voice"
|
|
15
|
+
|
|
16
|
+
export type PlaySoundOptions = {
|
|
17
|
+
/** 0 … 1 (and above, at your own risk). Default 1. */
|
|
18
|
+
volume?: number
|
|
19
|
+
/** Playback rate, 1 = unchanged. Default 1. */
|
|
20
|
+
pitch?: number
|
|
21
|
+
loop?: boolean
|
|
22
|
+
/** Bus name; default: the source's bus, `sfx` for 2D. */
|
|
23
|
+
bus?: string
|
|
24
|
+
/** Higher survives voice stealing when the pool is full. Default 0. */
|
|
25
|
+
priority?: number
|
|
26
|
+
/** Fade-in seconds. Default 0. */
|
|
27
|
+
fade?: number
|
|
28
|
+
/** Seconds into the clip to start from. */
|
|
29
|
+
startAt?: number
|
|
30
|
+
/** 2D only: -1 left … 1 right. */
|
|
31
|
+
pan?: number
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export type Rolloff = "none" | "inverse" | "linear" | "exponential"
|
|
35
|
+
|
|
36
|
+
/** Distance model of an AudioSource / playAt. */
|
|
37
|
+
export type SpatialOptions = {
|
|
38
|
+
/** Metres of full volume around the source. Default 1. */
|
|
39
|
+
minDistance?: number
|
|
40
|
+
/** Metres beyond which the source no longer gets quieter. Default 50. */
|
|
41
|
+
maxDistance?: number
|
|
42
|
+
/** How volume falls between the two: `inverse` (default, physical), `linear`, `exponential`, `none`. */
|
|
43
|
+
rolloff?: Rolloff
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export const ROLLOFF: Record<Rolloff, number> = { none: 0, inverse: 1, linear: 2, exponential: 3 }
|
|
47
|
+
|
|
48
|
+
/** @internal CAUD_SRC_* order. */
|
|
49
|
+
export function sourceParams(o: { minDistance: number, maxDistance: number, rolloff: Rolloff, coneInner: number, coneOuter: number, coneOuterGain: number, doppler: number, spread: number, occlusion: boolean, bus: number }): Float32Array {
|
|
50
|
+
return new Float32Array([ o.minDistance, o.maxDistance, ROLLOFF[o.rolloff] ?? 1, o.coneInner, o.coneOuter, o.coneOuterGain, o.doppler, o.spread, o.occlusion ? 1 : 0, o.bus ])
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export type AudioStats = {
|
|
54
|
+
voicesPlaying: number
|
|
55
|
+
voicesMono: number
|
|
56
|
+
voicesStereo: number
|
|
57
|
+
/** Voices displaced (or dropped) by a fuller pool since start. */
|
|
58
|
+
stolen: number
|
|
59
|
+
clips: number
|
|
60
|
+
clipBytes: number
|
|
61
|
+
/** Master peak since the previous read, linear (1 = full scale). */
|
|
62
|
+
peak: number
|
|
63
|
+
sampleRate: number
|
|
64
|
+
/** The reverb zone the listener is in (0 = none) and how far inside (0 … 1). */
|
|
65
|
+
listenerZone: number
|
|
66
|
+
zoneBlend: number
|
|
67
|
+
/** Voices rendered binaurally right now (see `audio.hrtf`). */
|
|
68
|
+
hrtfVoices: number
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const DEFAULT_BUSES = [ "master", "sfx", "music", "ui", "voice" ]
|
|
72
|
+
|
|
73
|
+
class AudioSystem {
|
|
74
|
+
private readonly _buses = new Map<string, Bus>()
|
|
75
|
+
private _hrtf = false
|
|
76
|
+
private _hrtfVoices = 16
|
|
77
|
+
private _timeScalePitch = false
|
|
78
|
+
|
|
79
|
+
/** True when this host mixes sound. Everything below is a silent no-op otherwise. */
|
|
80
|
+
get supported(): boolean { return audioSupported }
|
|
81
|
+
|
|
82
|
+
/** HRTF binaural rendering (MIT KEMAR filters on the CPU): sounds get a real up / down / behind in
|
|
83
|
+
* HEADPHONES. Off by default — on speakers it only smears the image. Applies to the `hrtfVoices`
|
|
84
|
+
* nearest 3D voices; the rest keep plain panning. */
|
|
85
|
+
get hrtf(): boolean { return this._hrtf }
|
|
86
|
+
set hrtf(v: boolean) { this._hrtf = v; if (audioSupported) _creatorAudio.setHrtf(v, this._hrtfVoices) }
|
|
87
|
+
/** `Time.scale` also pitches the sfx bus: slow motion drops every effect's tone, like a film. Default
|
|
88
|
+
* false — a pause only mutes sfx, the menu click keeps its pitch. */
|
|
89
|
+
get timeScalePitch(): boolean { return this._timeScalePitch }
|
|
90
|
+
set timeScalePitch(v: boolean) { this._timeScalePitch = v; if (audioSupported) _creatorAudio.setTimeScalePitch(v) }
|
|
91
|
+
/** How many voices get the (CPU-heavier) binaural path. Default 16. */
|
|
92
|
+
get hrtfVoices(): number { return this._hrtfVoices }
|
|
93
|
+
set hrtfVoices(n: number) { this._hrtfVoices = n; if (audioSupported && this._hrtf) _creatorAudio.setHrtf(true, n) }
|
|
94
|
+
|
|
95
|
+
/** A bus by name — the five built-ins, or an app-defined one created on first use. */
|
|
96
|
+
bus(name: string): Bus {
|
|
97
|
+
let b = this._buses.get(name)
|
|
98
|
+
if (b) return b
|
|
99
|
+
let id = -1
|
|
100
|
+
if (audioSupported) {
|
|
101
|
+
id = _creatorAudio.busId(name)
|
|
102
|
+
if (id < 0 && !DEFAULT_BUSES.includes(name)) id = _creatorAudio.createBus(name)
|
|
103
|
+
if (id < 0) console.warn(`audio.bus('${name}'): no free bus slot (16 max)`)
|
|
104
|
+
}
|
|
105
|
+
b = new Bus(name, id)
|
|
106
|
+
this._buses.set(name, b)
|
|
107
|
+
return b
|
|
108
|
+
}
|
|
109
|
+
get master(): Bus { return this.bus("master") }
|
|
110
|
+
|
|
111
|
+
/** @internal the bus id for an options object (or -1 = the source's). */
|
|
112
|
+
_busId(name: string | undefined): number {
|
|
113
|
+
if (name === undefined) return -1
|
|
114
|
+
return this.bus(name)._id
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** 2D playback (UI, stingers, music one-shots). */
|
|
118
|
+
play(sound: Sound, options: PlaySoundOptions = {}): Voice {
|
|
119
|
+
return this._play(sound, 0, options)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** A one-shot at a world position with its own transient source — impacts, ricochets, debris.
|
|
123
|
+
* The source is freed when the voice ends. */
|
|
124
|
+
playAt(sound: Sound, position: Vec3Like, options: PlaySoundOptions & SpatialOptions = {}): Voice {
|
|
125
|
+
if (!audioSupported || !sound.ready) return new Voice(0, options.volume ?? 1, options.pitch ?? 1, 0)
|
|
126
|
+
const src = _creatorAudio.createSource()
|
|
127
|
+
_creatorAudio.setSourceParams(src, sourceParams({
|
|
128
|
+
minDistance: options.minDistance ?? 1, maxDistance: options.maxDistance ?? 50, rolloff: options.rolloff ?? "inverse",
|
|
129
|
+
coneInner: 360, coneOuter: 360, coneOuterGain: 1, doppler: 0, spread: 0, occlusion: false, bus: this._busId(options.bus ?? "sfx"),
|
|
130
|
+
}))
|
|
131
|
+
_creatorAudio.setSourcePosition(src, cx(position), cy(position), cz(position))
|
|
132
|
+
const v = this._play(sound, src, options)
|
|
133
|
+
if (v._tok === 0) { _creatorAudio.destroySource(src); return v }
|
|
134
|
+
v._ownSource = src
|
|
135
|
+
v._watch()
|
|
136
|
+
return v
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** @internal shared by play / playAt / AudioSource.play. */
|
|
140
|
+
_play(sound: Sound, sourceId: number, options: PlaySoundOptions): Voice {
|
|
141
|
+
const volume = options.volume ?? 1, pitch = options.pitch ?? 1, pan = options.pan ?? 0
|
|
142
|
+
if (!audioSupported || !sound.ready) return new Voice(0, volume, pitch, pan)
|
|
143
|
+
const clip = sound._pick()
|
|
144
|
+
if (clip === 0) return new Voice(0, volume, pitch, pan)
|
|
145
|
+
const tok = _creatorAudio.play(clip, sourceId, this._busId(options.bus), volume, pitch, !!options.loop,
|
|
146
|
+
options.priority ?? 0, options.fade ?? 0, options.startAt ?? 0, pan)
|
|
147
|
+
return new Voice(tok, volume, pitch, pan)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Stop every voice on every bus (fade in seconds). */
|
|
151
|
+
stopAll(fade = 0): void { if (audioSupported) _creatorAudio.stopAll(fade) }
|
|
152
|
+
|
|
153
|
+
/** Engine counters for a debug overlay or a perf log. */
|
|
154
|
+
get stats(): AudioStats {
|
|
155
|
+
const s = audioSupported ? _creatorAudio.stats() : new Float32Array(11)
|
|
156
|
+
return { voicesPlaying: s[0], voicesMono: s[1], voicesStereo: s[2], stolen: s[3], clips: s[4], clipBytes: s[5], peak: s[6], sampleRate: s[7], listenerZone: s[8], zoneBlend: s[9], hrtfVoices: s[10] ?? 0 }
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export const audio = new AudioSystem()
|
|
161
|
+
export type { AudioSystem }
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// Feature gate for the game audio system (docs/audio-plan.md). `_creatorAudio` is OPTIONAL as a
|
|
2
|
+
// block: a host without it (or whose engine failed to open a device) makes every Sound silent and
|
|
3
|
+
// every Voice inert — a project stays runnable, nothing throws.
|
|
4
|
+
|
|
5
|
+
export const audioSupported: boolean =
|
|
6
|
+
typeof _creatorAudio !== "undefined" && !!_creatorAudio && typeof _creatorAudio.hasSupport === "function" && _creatorAudio.hasSupport()
|