diff --git a/demos/demos/audio/audioOscillator.js b/demos/demos/audio/audioOscillator.js new file mode 100644 index 00000000..2f1245c8 --- /dev/null +++ b/demos/demos/audio/audioOscillator.js @@ -0,0 +1,28 @@ +import { + Demo, + World, + AudioOscillator, + EntityCommands, + Cleanup +} from 'wima' +import { addDefaultCamera3D } from '../utils.js' + +export default new Demo( + 'audio/audio oscillator', + [init, addDefaultCamera3D] +) + +/** + * @param {World} world + */ +function init(world) { + const commands = world.getResource(EntityCommands) + + commands + .spawn() + .insertPrefab([ + new AudioOscillator(), + new Cleanup() + ]) + .build() +} \ No newline at end of file diff --git a/demos/demos/audio/index.js b/demos/demos/audio/index.js index a32ad610..bff6271d 100644 --- a/demos/demos/audio/index.js +++ b/demos/demos/audio/index.js @@ -1,2 +1,3 @@ export { default as audioGraph } from './audioGraph.js' -export { default as audioPlayer } from './audioPlayer.js' \ No newline at end of file +export { default as audioPlayer } from './audioPlayer.js' +export { default as audioOscillator } from './audioOscillator.js' \ No newline at end of file diff --git a/demos/main.js b/demos/main.js index e4bc16d7..e2bbb9c8 100644 --- a/demos/main.js +++ b/demos/main.js @@ -21,6 +21,7 @@ import { despawn, audioGraph, audioPlayer, + audioOscillator, keyboard, mouse, touch, @@ -71,6 +72,7 @@ app touch, audioGraph, audioPlayer, + audioOscillator, lineStyle2d, arcs2d, shapes2d, diff --git a/src/audio/components/audiooscillator.js b/src/audio/components/audiooscillator.js new file mode 100644 index 00000000..62d256f7 --- /dev/null +++ b/src/audio/components/audiooscillator.js @@ -0,0 +1,89 @@ +/** @import { NodeId } from '../../datastructures/index.js' */ +/** @import { ComponentHook } from '../../ecs/index.js' */ +import { Timer } from '../../time/index.js' +import { AudioGraph } from '../resources/index.js' + +export class AudioOscillator { + + /** + * @type {NodeId | undefined} + */ + sourceNode + + /** + * @type {AudioOscillatorType} + */ + type + + /** + * @type {NodeId | undefined} + */ + attach + + /** + * @type {number} + */ + detune + + /** + * @type {number} + */ + frequency + + /** + * @type {Timer} + */ + playback = new Timer({ duration:1000000 }) + + /** + * @param {AudioOscillatorOptions} [options] + */ + constructor({ + attach, + type = AudioOscillatorType.Sine, + detune = 0, + frequency = 440 + } = {}) { + this.attach = attach + this.type = type + this.detune = detune + this.frequency = frequency + } +} + +/** + * @type {ComponentHook} + */ +export function removeOscillatorSink(entity, world) { + const graph = world.getResource(AudioGraph) + const audio = world.get(entity, AudioOscillator) + + // SAFETY: The node referenced by the player is guaranteed to be a `OscillatorNode`. + const node = /** @type {OscillatorNode | undefined} */ (graph.graph.getNode(audio.sourceNode)?.weight) + + if (node) { + node.stop() + + // TODO: Remove the audio sink from the graph when removing nodes on a graph + // is available. + } +} + +/** + * @readonly + * @enum {number} + */ +export const AudioOscillatorType = { + SawTooth: 0, + Sine: 1, + Triangle: 2, + Square: 3 +} + +/** + * @typedef AudioOscillatorOptions + * @property {NodeId} [attach] + * @property {AudioOscillatorType} [type] + * @property {number} [frequency] + * @property {number} [detune] + */ \ No newline at end of file diff --git a/src/audio/components/index.js b/src/audio/components/index.js index 711ef554..7a24542e 100644 --- a/src/audio/components/index.js +++ b/src/audio/components/index.js @@ -1 +1,2 @@ -export * from './audioplayer.js' \ No newline at end of file +export * from './audioplayer.js' +export * from './audiooscillator.js' \ No newline at end of file diff --git a/src/audio/plugin.js b/src/audio/plugin.js index c2f739bd..cc4a9fe7 100644 --- a/src/audio/plugin.js +++ b/src/audio/plugin.js @@ -3,10 +3,10 @@ import { AssetParserPlugin, AssetPlugin, Assets } from '../asset/index.js' import { ComponentHooks } from '../ecs/index.js' import { typeidGeneric } from '../reflect/index.js' import { Audio } from './assets/index.js' -import { AudioPlayer, removeAudioPlayerSink } from './components/index.js' +import { AudioPlayer, AudioOscillator, removeAudioPlayerSink, removeOscillatorSink } from './components/index.js' import { AudioAdded, AudioDropped, AudioModified } from './events/index.js' import { AudioCommands, AudioParser, AudioAssets, AudioGraph } from './resources/index.js' -import { playAudio, updatePlayers } from './systems/index.js' +import { playAudio, updatePlayers, playOscillators, updateOscillators } from './systems/index.js' export class AudioPlugin extends Plugin { @@ -23,6 +23,11 @@ export class AudioPlugin extends Plugin { null, removeAudioPlayerSink )) + .registerType(AudioOscillator) + .setComponentHooks(AudioOscillator, new ComponentHooks( + null, + removeOscillatorSink + )) .setResource(new AudioGraph()) .setResource(handler) .registerPlugin(new AssetPlugin({ @@ -38,7 +43,9 @@ export class AudioPlugin extends Plugin { parser: new AudioParser() })) .registerSystem(AppSchedule.Update, playAudio) + .registerSystem(AppSchedule.Update, playOscillators) .registerSystem(AppSchedule.Update, updatePlayers) + .registerSystem(AppSchedule.Update, updateOscillators) window.addEventListener('pointerdown', resumeAudio) diff --git a/src/audio/systems/index.js b/src/audio/systems/index.js index b3e715af..37d74372 100644 --- a/src/audio/systems/index.js +++ b/src/audio/systems/index.js @@ -1,7 +1,7 @@ import { Query, World } from '../../ecs/index.js' import { VirtualClock, TimerMode } from '../../time/index.js' import { AudioAssets, AudioGraph } from '../resources/index.js' -import { AudioPlayer } from '../components/index.js' +import { AudioPlayer, AudioOscillator, AudioOscillatorType } from '../components/index.js' /** @@ -48,6 +48,42 @@ export function playAudio(world) { }) } +/** + * @param {World} world + */ +export function playOscillators(world) { + const graph = world.getResource(AudioGraph) + const oscillators = new Query(world, [AudioOscillator]) + const ctx = graph.getContext() + const root = graph.getRoot() + + + oscillators.each(([oscillator]) => { + const { type, frequency, sourceNode, detune, playback, attach } = oscillator + + if (sourceNode) { + + // update node if a playback is requested + } else { + const node = new OscillatorNode(ctx, { + detune, + frequency, + type: mapType(type) + }) + const id = graph.add(node) + + if (attach) { + graph.connect(id, attach) + } else { + graph.connect(id, root) + } + + node.start(playback.elapsed()) + oscillator.sourceNode = id + } + }) +} + /** * @param {World} world */ @@ -61,6 +97,19 @@ export function updatePlayers(world) { }) } +/** + * @param {World} world + */ +export function updateOscillators(world) { + const oscillators = new Query(world, [AudioOscillator]) + const clock = world.getResource(VirtualClock) + const delta = clock.getDelta() + + oscillators.each(([oscillator]) => { + oscillator.playback.update(delta) + }) +} + /** * @param {TimerMode} playbackMode */ @@ -75,4 +124,28 @@ function looped(playbackMode) { default: return false } +} + +/** + * @param {AudioOscillatorType} type + * @throws {string} + * @returns {OscillatorType} + */ +function mapType(type) { + switch (type) { + case AudioOscillatorType.SawTooth: + return 'sawtooth' + + case AudioOscillatorType.Sine: + return 'sine' + + case AudioOscillatorType.Square: + return 'square' + + case AudioOscillatorType.Triangle: + return 'triangle' + + default: + throw 'No such `AudioOscillatorType` exists.' + } } \ No newline at end of file