| @@ -5,12 +5,10 @@ | |||||
| <script lang="ts"> | <script lang="ts"> | ||||
| import { Options, Vue } from "vue-class-component"; | import { Options, Vue } from "vue-class-component"; | ||||
| import VoreAudio from "./components/VoreAudio.vue"; | import VoreAudio from "./components/VoreAudio.vue"; | ||||
| import SourceNode from "./components/sources/SourceNode.vue"; | |||||
| @Options({ | @Options({ | ||||
| components: { | components: { | ||||
| VoreAudio, | VoreAudio, | ||||
| SourceNode, | |||||
| }, | }, | ||||
| }) | }) | ||||
| export default class App extends Vue {} | export default class App extends Vue {} | ||||
| @@ -1,4 +1,5 @@ | |||||
| import "reflect-metadata"; | import "reflect-metadata"; | ||||
| import Source from "./sources/Source"; | |||||
| export abstract class Node { | export abstract class Node { | ||||
| constructor(public name: string) {} | constructor(public name: string) {} | ||||
| @@ -18,7 +19,8 @@ export type RangeMetadata = { | |||||
| export const exposedMetadataNumber = Symbol("exposedNumber"); | export const exposedMetadataNumber = Symbol("exposedNumber"); | ||||
| function exposedNumber(name: string, min: number, max: number) { | |||||
| // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types | |||||
| export function exposedNumber(name: string, min: number, max: number) { | |||||
| return Reflect.metadata(exposedMetadataNumber, { | return Reflect.metadata(exposedMetadataNumber, { | ||||
| name: name, | name: name, | ||||
| min: min, | min: min, | ||||
| @@ -28,7 +30,8 @@ function exposedNumber(name: string, min: number, max: number) { | |||||
| export const exposedRangeMetadata = Symbol("exposedRange"); | export const exposedRangeMetadata = Symbol("exposedRange"); | ||||
| function exposedRange(name: string, min: number, max: number) { | |||||
| // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types | |||||
| export function exposedRange(name: string, min: number, max: number) { | |||||
| return Reflect.metadata(exposedRangeMetadata, { | return Reflect.metadata(exposedRangeMetadata, { | ||||
| name: name, | name: name, | ||||
| min: min, | min: min, | ||||
| @@ -36,151 +39,7 @@ function exposedRange(name: string, min: number, max: number) { | |||||
| }); | }); | ||||
| } | } | ||||
| export abstract class Source extends Node { | |||||
| public abstract kind: string; | |||||
| protected sounds: Array<AudioBuffer> = []; | |||||
| public gain: GainNode; | |||||
| public output: GainNode; | |||||
| public _active = true; | |||||
| get active(): boolean { | |||||
| return this._active; | |||||
| } | |||||
| set active(value: boolean) { | |||||
| this._active = value; | |||||
| this.output.gain.linearRampToValueAtTime( | |||||
| value ? 1.0 : 0.0, | |||||
| context.currentTime + 0.5 | |||||
| ); | |||||
| } | |||||
| @exposedNumber("Volume", 0, 1) | |||||
| public volume = 1; | |||||
| constructor(name: string) { | |||||
| super(name); | |||||
| this.gain = context.createGain(); | |||||
| this.output = context.createGain(); | |||||
| this.gain.connect(this.output); | |||||
| } | |||||
| public loadSound(name: string): void { | |||||
| loadAudio(name, this); | |||||
| } | |||||
| public addLoadedSound(sound: AudioBuffer): void { | |||||
| this.sounds.push(sound); | |||||
| } | |||||
| public abstract start(): void; | |||||
| public tick(dt: number): void { | |||||
| this.gain.gain.value = this.volume; | |||||
| } | |||||
| } | |||||
| export class IntervalSource extends Source { | |||||
| kind = "Interval"; | |||||
| @exposedRange("Interval", 0.25, 30) | |||||
| public interval: [number, number] = [1, 5]; | |||||
| @exposedRange("Panning", -1, 1) | |||||
| public panning: [number, number] = [-0.2, 0.2]; | |||||
| private remaining = 0; | |||||
| private started = false; | |||||
| constructor( | |||||
| name: string, | |||||
| minTime: number, | |||||
| maxTime: number, | |||||
| public randomness = 0 | |||||
| ) { | |||||
| super(name); | |||||
| this.interval = [minTime, maxTime]; | |||||
| this.setTimer(); | |||||
| } | |||||
| private setTimer(): void { | |||||
| this.remaining = this.interval[0]; | |||||
| this.remaining += (this.interval[1] - this.interval[0]) * Math.random(); | |||||
| this.remaining *= 1000; | |||||
| } | |||||
| public start(): void { | |||||
| this.started = true; | |||||
| } | |||||
| public tick(dt: number): void { | |||||
| super.tick(dt); | |||||
| if (this.started) { | |||||
| this.remaining -= dt; | |||||
| if (this.remaining <= 0) { | |||||
| const index = Math.floor(Math.random() * this.sounds.length); | |||||
| const node = context.createBufferSource(); | |||||
| node.buffer = this.sounds[index]; | |||||
| const pan = context.createStereoPanner(); | |||||
| pan.pan.value = | |||||
| Math.random() * (this.panning[1] - this.panning[0]) + this.panning[0]; | |||||
| node.connect(pan); | |||||
| pan.connect(this.gain); | |||||
| node.start(); | |||||
| node.onended = () => { | |||||
| pan.disconnect(); | |||||
| }; | |||||
| this.setTimer(); | |||||
| } | |||||
| } | |||||
| } | |||||
| } | |||||
| export class LoopingSource extends Source { | |||||
| kind = "Looping"; | |||||
| private source!: AudioBufferSourceNode; | |||||
| private started = false; | |||||
| private running = false; | |||||
| constructor(name: string) { | |||||
| super(name); | |||||
| } | |||||
| public start(): void { | |||||
| this.started = true; | |||||
| } | |||||
| private pickRandom(): void { | |||||
| const index = Math.floor(Math.random() * this.sounds.length); | |||||
| this.source = context.createBufferSource(); | |||||
| this.source.buffer = this.sounds[index]; | |||||
| this.source.connect(this.gain); | |||||
| this.source.onended = () => { | |||||
| this.pickRandom(); | |||||
| this.source.start(); | |||||
| }; | |||||
| } | |||||
| public tick(dt: number): void { | |||||
| super.tick(dt); | |||||
| if (this.started && this.sounds.length > 0 && !this.running) { | |||||
| this.pickRandom(); | |||||
| this.source.start(); | |||||
| this.running = true; | |||||
| } | |||||
| } | |||||
| } | |||||
| let context: AudioContext; | |||||
| export let context: AudioContext; | |||||
| const audioBaseUrl = "/audio/"; | const audioBaseUrl = "/audio/"; | ||||
| @@ -189,7 +48,7 @@ const audioDict: Map<string, AudioBuffer> = new Map(); | |||||
| // asynchronously load an audio file | // asynchronously load an audio file | ||||
| function loadAudio(name: string, source: Source, flush = false) { | |||||
| export function loadAudio(name: string, source: Source, flush = false) { | |||||
| // are we already trying to get the audio? | // are we already trying to get the audio? | ||||
| if (!waiting.has(name)) { | if (!waiting.has(name)) { | ||||
| @@ -7,7 +7,7 @@ | |||||
| > | > | ||||
| {{ metadata.name }} | {{ metadata.name }} | ||||
| <Slider | <Slider | ||||
| v-model="source[metadata.key]" | |||||
| v-model="node[metadata.key]" | |||||
| :min="metadata.min" | :min="metadata.min" | ||||
| :max="metadata.max" | :max="metadata.max" | ||||
| :step="-1" | :step="-1" | ||||
| @@ -17,7 +17,7 @@ | |||||
| <div class="node-prop" v-for="(metadata, index) in rangeProps" :key="index"> | <div class="node-prop" v-for="(metadata, index) in rangeProps" :key="index"> | ||||
| {{ metadata.name }} | {{ metadata.name }} | ||||
| <Slider | <Slider | ||||
| v-model="source[metadata.key]" | |||||
| v-model="node[metadata.key]" | |||||
| :min="metadata.min" | :min="metadata.min" | ||||
| :max="metadata.max" | :max="metadata.max" | ||||
| :step="-1" | :step="-1" | ||||
| @@ -33,31 +33,31 @@ import { | |||||
| exposedRangeMetadata, | exposedRangeMetadata, | ||||
| NumberMetadata, | NumberMetadata, | ||||
| RangeMetadata, | RangeMetadata, | ||||
| Source, | |||||
| } from "@/audio"; | } from "@/audio"; | ||||
| import { Options, Vue } from "vue-class-component"; | import { Options, Vue } from "vue-class-component"; | ||||
| import Slider from "@vueform/slider"; | import Slider from "@vueform/slider"; | ||||
| import { Node } from "@/audio"; | |||||
| @Options({ | @Options({ | ||||
| props: { | props: { | ||||
| source: Source, | |||||
| node: Node, | |||||
| }, | }, | ||||
| components: { | components: { | ||||
| Slider, | Slider, | ||||
| }, | }, | ||||
| }) | }) | ||||
| export default class NodeProps extends Vue { | export default class NodeProps extends Vue { | ||||
| source!: Source; | |||||
| node!: Node; | |||||
| numberProps: Array<{ name: string; key: string; min: number; max: number }> = | numberProps: Array<{ name: string; key: string; min: number; max: number }> = | ||||
| []; | []; | ||||
| rangeProps: Array<{ name: string; key: string; min: number; max: number }> = | rangeProps: Array<{ name: string; key: string; min: number; max: number }> = | ||||
| []; | []; | ||||
| mounted(): void { | mounted(): void { | ||||
| Object.keys(this.source).forEach((key) => { | |||||
| Object.keys(this.node).forEach((key) => { | |||||
| const metadata: NumberMetadata | undefined = Reflect.getMetadata( | const metadata: NumberMetadata | undefined = Reflect.getMetadata( | ||||
| exposedMetadataNumber, | exposedMetadataNumber, | ||||
| this.source, | |||||
| this.node, | |||||
| key | key | ||||
| ); | ); | ||||
| @@ -71,10 +71,10 @@ export default class NodeProps extends Vue { | |||||
| } | } | ||||
| }); | }); | ||||
| Object.keys(this.source).forEach((key) => { | |||||
| Object.keys(this.node).forEach((key) => { | |||||
| const metadata: RangeMetadata | undefined = Reflect.getMetadata( | const metadata: RangeMetadata | undefined = Reflect.getMetadata( | ||||
| exposedRangeMetadata, | exposedRangeMetadata, | ||||
| this.source, | |||||
| this.node, | |||||
| key | key | ||||
| ); | ); | ||||
| @@ -87,9 +87,6 @@ export default class NodeProps extends Vue { | |||||
| }); | }); | ||||
| } | } | ||||
| }); | }); | ||||
| console.log(this.numberProps[0]); | |||||
| console.log(this.rangeProps[0]); | |||||
| } | } | ||||
| } | } | ||||
| </script> | </script> | ||||
| @@ -9,7 +9,7 @@ | |||||
| <div> | <div> | ||||
| Many sounds by <a href="https://www.furaffinity.net/user/jeschke">Jit</a>! | Many sounds by <a href="https://www.furaffinity.net/user/jeschke">Jit</a>! | ||||
| </div> | </div> | ||||
| <button v-on:click="start">Start</button> | |||||
| <button v-on:click="start" class="start-button" v-if="!started">Start</button> | |||||
| <div class="soundscape"> | <div class="soundscape"> | ||||
| <source-node | <source-node | ||||
| v-for="(source, index) in sources" | v-for="(source, index) in sources" | ||||
| @@ -17,6 +17,12 @@ | |||||
| :source="source" | :source="source" | ||||
| > | > | ||||
| </source-node> | </source-node> | ||||
| <filter-node | |||||
| v-for="(filter, index) in filters" | |||||
| :key="index" | |||||
| :filter="filter" | |||||
| > | |||||
| </filter-node> | |||||
| </div> | </div> | ||||
| <div></div> | <div></div> | ||||
| @@ -24,15 +30,15 @@ | |||||
| </template> | </template> | ||||
| <script lang="ts"> | <script lang="ts"> | ||||
| import { | |||||
| clearCache, | |||||
| IntervalSource, | |||||
| LoopingSource, | |||||
| setup, | |||||
| Source, | |||||
| } from "@/audio"; | |||||
| import { clearCache, setup } from "@/audio"; | |||||
| import { Options, Vue } from "vue-class-component"; | import { Options, Vue } from "vue-class-component"; | ||||
| import SourceNode from "./sources/SourceNode.vue"; | |||||
| import Source from "@/sources/Source"; | |||||
| import SourceNode from "./nodes/SourceNode.vue"; | |||||
| import FilterNode from "./nodes/FilterNode.vue"; | |||||
| import LoopingSource from "@/sources/LoopingSource"; | |||||
| import IntervalSource from "@/sources/IntervalSource"; | |||||
| import Filter from "@/filters/Filter"; | |||||
| import BiquadFilter from "@/filters/LowpassFilter"; | |||||
| @Options({ | @Options({ | ||||
| props: { | props: { | ||||
| @@ -40,11 +46,37 @@ import SourceNode from "./sources/SourceNode.vue"; | |||||
| }, | }, | ||||
| components: { | components: { | ||||
| SourceNode, | SourceNode, | ||||
| FilterNode, | |||||
| }, | }, | ||||
| }) | }) | ||||
| export default class VoreAudio extends Vue { | export default class VoreAudio extends Vue { | ||||
| started = false; | |||||
| context!: AudioContext; | context!: AudioContext; | ||||
| sources: Array<Source> = []; | sources: Array<Source> = []; | ||||
| filters: Array<Filter> = []; | |||||
| filterBus!: GainNode; | |||||
| addSource(source: Source): void { | |||||
| source.output.connect(this.filterBus); | |||||
| this.sources.push(source); | |||||
| source.start(); | |||||
| } | |||||
| addFilter(filter: Filter): void { | |||||
| if (this.filters.length > 0) { | |||||
| const last: Filter = this.filters[this.filters.length - 1]; | |||||
| last.output.disconnect(); | |||||
| last.output.connect(filter.input); | |||||
| filter.output.connect(this.context.destination); | |||||
| } else { | |||||
| this.filterBus.disconnect(); | |||||
| this.filterBus.connect(filter.input); | |||||
| filter.output.connect(this.context.destination); | |||||
| } | |||||
| filter.start(); | |||||
| this.filters.push(filter); | |||||
| } | |||||
| startGlorps(): void { | startGlorps(): void { | ||||
| const source: Source = new IntervalSource("Guts", 5, 8); | const source: Source = new IntervalSource("Guts", 5, 8); | ||||
| @@ -57,13 +89,9 @@ export default class VoreAudio extends Vue { | |||||
| source.loadSound("stomach-churn.ogg"); | source.loadSound("stomach-churn.ogg"); | ||||
| source.loadSound("bowels-churn-safe.ogg"); | source.loadSound("bowels-churn-safe.ogg"); | ||||
| source.loadSound("bowels-churn-danger.ogg"); | source.loadSound("bowels-churn-danger.ogg"); | ||||
| source.output.connect(this.context.destination); | |||||
| source.start(); | |||||
| setInterval(() => source.tick(100), 100); | |||||
| source.active = false; | source.active = false; | ||||
| this.sources.push(source); | |||||
| this.addSource(source); | |||||
| } | } | ||||
| startDigestion(): void { | startDigestion(): void { | ||||
| @@ -71,12 +99,8 @@ export default class VoreAudio extends Vue { | |||||
| source.loadSound("fen-stomach.ogg"); | source.loadSound("fen-stomach.ogg"); | ||||
| source.loadSound("fen-intestines.ogg"); | source.loadSound("fen-intestines.ogg"); | ||||
| source.loadSound("fen-bowels.ogg"); | source.loadSound("fen-bowels.ogg"); | ||||
| source.output.connect(this.context.destination); | |||||
| source.start(); | |||||
| console.log(source); | |||||
| setInterval(() => source.tick(100), 100); | |||||
| this.sources.push(source); | |||||
| this.addSource(source); | |||||
| } | } | ||||
| startBurps(): void { | startBurps(): void { | ||||
| @@ -97,14 +121,10 @@ export default class VoreAudio extends Vue { | |||||
| source.loadSound("belch (14).ogg"); | source.loadSound("belch (14).ogg"); | ||||
| source.loadSound("belch (15).ogg"); | source.loadSound("belch (15).ogg"); | ||||
| source.loadSound("belch (16).ogg"); | source.loadSound("belch (16).ogg"); | ||||
| source.output.connect(this.context.destination); | |||||
| source.start(); | |||||
| console.log(source); | |||||
| setInterval(() => source.tick(100), 100); | |||||
| source.active = false; | source.active = false; | ||||
| this.sources.push(source); | |||||
| this.addSource(source); | |||||
| } | } | ||||
| startGurgles(): void { | startGurgles(): void { | ||||
| @@ -130,13 +150,8 @@ export default class VoreAudio extends Vue { | |||||
| source.loadSound("gurgles/gurgle (19).ogg"); | source.loadSound("gurgles/gurgle (19).ogg"); | ||||
| source.loadSound("gurgles/gurgle (20).ogg"); | source.loadSound("gurgles/gurgle (20).ogg"); | ||||
| source.loadSound("gurgles/gurgle (21).ogg"); | source.loadSound("gurgles/gurgle (21).ogg"); | ||||
| source.output.connect(this.context.destination); | |||||
| source.start(); | |||||
| source.volume = 0.5; | |||||
| console.log(source); | |||||
| setInterval(() => source.tick(100), 100); | |||||
| this.sources.push(source); | |||||
| this.addSource(source); | |||||
| } | } | ||||
| clear(): void { | clear(): void { | ||||
| @@ -144,14 +159,29 @@ export default class VoreAudio extends Vue { | |||||
| } | } | ||||
| start(): void { | start(): void { | ||||
| if (this.started) { | |||||
| return; | |||||
| } | |||||
| this.started = true; | |||||
| this.startGlorps(); | this.startGlorps(); | ||||
| this.startGurgles(); | this.startGurgles(); | ||||
| this.startDigestion(); | this.startDigestion(); | ||||
| this.startBurps(); | this.startBurps(); | ||||
| const filter: Filter = new BiquadFilter(); | |||||
| filter.active = false; | |||||
| this.addFilter(filter); | |||||
| setInterval(() => { | |||||
| this.sources.forEach((source) => source.tick(100)); | |||||
| this.filters.forEach((filter) => filter.tick(100)); | |||||
| }, 100); | |||||
| } | } | ||||
| mounted(): void { | mounted(): void { | ||||
| this.context = setup(); | this.context = setup(); | ||||
| this.filterBus = this.context.createGain(); | |||||
| this.filterBus.connect(this.context.destination); | |||||
| } | } | ||||
| } | } | ||||
| </script> | </script> | ||||
| @@ -160,12 +190,15 @@ export default class VoreAudio extends Vue { | |||||
| .soundscape { | .soundscape { | ||||
| margin: auto; | margin: auto; | ||||
| padding: 20px; | padding: 20px; | ||||
| width: 50vw; | |||||
| min-width: 1000px; | |||||
| width: minmax(50vw, 1500px); | |||||
| height: 100%; | height: 100%; | ||||
| display: grid; | display: grid; | ||||
| grid-template-columns: 1fr 1fr; | |||||
| grid-template-columns: repeat(auto-fit, minmax(400px, 1fr)); | |||||
| grid-auto-rows: 200px; | grid-auto-rows: 200px; | ||||
| grid-gap: 20px; | grid-gap: 20px; | ||||
| } | } | ||||
| .start-button { | |||||
| font-size: 60pt; | |||||
| } | |||||
| </style> | </style> | ||||
| @@ -0,0 +1,61 @@ | |||||
| <template> | |||||
| <div :class="filter.active ? '' : 'inactive'" class="filter-node"> | |||||
| <Toggle class="active-toggle" v-model="filter.active" /> | |||||
| <div class="node-name">{{ filter.name }}</div> | |||||
| <node-props :node="filter"></node-props> | |||||
| </div> | |||||
| </template> | |||||
| <script lang="ts"> | |||||
| import Filter from "@/filters/Filter"; | |||||
| import { Options, Vue } from "vue-class-component"; | |||||
| import NodeProps from "@/components/NodeProps.vue"; | |||||
| import Toggle from "@vueform/toggle"; | |||||
| @Options({ | |||||
| props: { | |||||
| filter: Filter, | |||||
| }, | |||||
| components: { | |||||
| NodeProps, | |||||
| Toggle, | |||||
| }, | |||||
| }) | |||||
| export default class FilterNode extends Vue { | |||||
| filter!: Filter; | |||||
| } | |||||
| </script> | |||||
| <style scoped> | |||||
| .filter-node { | |||||
| width: 100%; | |||||
| height: 100%; | |||||
| background: #333; | |||||
| display: flex; | |||||
| flex-direction: column; | |||||
| position: relative; | |||||
| transition: 0.2s background; | |||||
| } | |||||
| .filter-node.inactive { | |||||
| background: #555; | |||||
| } | |||||
| .node-name { | |||||
| font-size: 24pt; | |||||
| margin: 4pt; | |||||
| color: #fcf; | |||||
| } | |||||
| .node-properties { | |||||
| display: flex; | |||||
| flex-direction: column; | |||||
| } | |||||
| .active-toggle { | |||||
| position: absolute; | |||||
| top: 10px; | |||||
| left: 10px; | |||||
| } | |||||
| </style> | |||||
| @@ -2,12 +2,12 @@ | |||||
| <div :class="source.active ? '' : 'inactive'" class="source-node"> | <div :class="source.active ? '' : 'inactive'" class="source-node"> | ||||
| <Toggle class="active-toggle" v-model="source.active" /> | <Toggle class="active-toggle" v-model="source.active" /> | ||||
| <div class="node-name">{{ source.name }}</div> | <div class="node-name">{{ source.name }}</div> | ||||
| <node-props :source="source"></node-props> | |||||
| <node-props :node="source"></node-props> | |||||
| </div> | </div> | ||||
| </template> | </template> | ||||
| <script lang="ts"> | <script lang="ts"> | ||||
| import { Source } from "@/audio"; | |||||
| import Source from "@/sources/Source"; | |||||
| import { Options, Vue } from "vue-class-component"; | import { Options, Vue } from "vue-class-component"; | ||||
| import NodeProps from "@/components/NodeProps.vue"; | import NodeProps from "@/components/NodeProps.vue"; | ||||
| import Toggle from "@vueform/toggle"; | import Toggle from "@vueform/toggle"; | ||||
| @@ -15,7 +15,7 @@ import Toggle from "@vueform/toggle"; | |||||
| @Options({ | @Options({ | ||||
| props: { | props: { | ||||
| source: Source, | source: Source, | ||||
| }, | |||||
| }, | |||||
| components: { | components: { | ||||
| NodeProps, | NodeProps, | ||||
| Toggle, | Toggle, | ||||
| @@ -0,0 +1,52 @@ | |||||
| import { Node, context } from "../audio"; | |||||
| export default abstract class Filter extends Node { | |||||
| public abstract kind: string; | |||||
| public input: GainNode; | |||||
| protected filterInput: GainNode; | |||||
| protected bypass: GainNode; | |||||
| public output: GainNode; | |||||
| private started = false; | |||||
| public _active = true; | |||||
| get active(): boolean { | |||||
| return this._active; | |||||
| } | |||||
| set active(value: boolean) { | |||||
| this._active = value; | |||||
| if (this.started) { | |||||
| this.bypass.gain.setTargetAtTime(value ? 0 : 1, context.currentTime, 0.3); | |||||
| this.filterInput.gain.setTargetAtTime( | |||||
| value ? 1 : 0, | |||||
| context.currentTime, | |||||
| 0.3 | |||||
| ); | |||||
| } else { | |||||
| this.bypass.gain.value = value ? 0 : 1; | |||||
| this.filterInput.gain.value = value ? 1 : 0; | |||||
| } | |||||
| } | |||||
| constructor(name: string) { | |||||
| super(name); | |||||
| this.input = context.createGain(); | |||||
| this.filterInput = context.createGain(); | |||||
| this.bypass = context.createGain(); | |||||
| this.output = context.createGain(); | |||||
| this.input.connect(this.filterInput); | |||||
| this.input.connect(this.bypass); | |||||
| this.bypass.connect(this.output); | |||||
| this.active = true; | |||||
| } | |||||
| public start(): void { | |||||
| this.started = true; | |||||
| } | |||||
| public tick(dt: number): void { | |||||
| /* */ | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,23 @@ | |||||
| import Filter from "./Filter"; | |||||
| import { context, exposedNumber } from "../audio"; | |||||
| export default class BiquadFilter extends Filter { | |||||
| public kind = "Biquad Filter"; | |||||
| private biquad: BiquadFilterNode; | |||||
| @exposedNumber("Cutoff", 10, 10000) | |||||
| public cutoff = 1000; | |||||
| constructor() { | |||||
| super("Lowpass Filter"); | |||||
| this.biquad = context.createBiquadFilter(); | |||||
| this.biquad.frequency.value = 100; | |||||
| this.filterInput.connect(this.biquad); | |||||
| this.biquad.connect(this.output); | |||||
| } | |||||
| public tick(dt: number): void { | |||||
| super.tick(dt); | |||||
| this.biquad.frequency.value = this.cutoff; | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,68 @@ | |||||
| import Source from "./Source"; | |||||
| import { exposedRange, context } from "../audio"; | |||||
| export default class IntervalSource extends Source { | |||||
| kind = "Interval"; | |||||
| @exposedRange("Interval", 0.25, 30) | |||||
| public interval: [number, number] = [1, 5]; | |||||
| @exposedRange("Panning", -1, 1) | |||||
| public panning: [number, number] = [-0.2, 0.2]; | |||||
| private remaining = 0; | |||||
| private started = false; | |||||
| constructor( | |||||
| name: string, | |||||
| minTime: number, | |||||
| maxTime: number, | |||||
| public randomness = 0 | |||||
| ) { | |||||
| super(name); | |||||
| this.interval = [minTime, maxTime]; | |||||
| this.setTimer(); | |||||
| } | |||||
| private setTimer(): void { | |||||
| this.remaining = this.interval[0]; | |||||
| this.remaining += (this.interval[1] - this.interval[0]) * Math.random(); | |||||
| this.remaining *= 1000; | |||||
| } | |||||
| public start(): void { | |||||
| this.started = true; | |||||
| } | |||||
| public tick(dt: number): void { | |||||
| super.tick(dt); | |||||
| if (this.started) { | |||||
| this.remaining -= dt; | |||||
| if (this.remaining <= 0) { | |||||
| const index = Math.floor(Math.random() * this.sounds.length); | |||||
| const node = context.createBufferSource(); | |||||
| node.buffer = this.sounds[index]; | |||||
| const pan = context.createStereoPanner(); | |||||
| pan.pan.value = | |||||
| Math.random() * (this.panning[1] - this.panning[0]) + this.panning[0]; | |||||
| node.connect(pan); | |||||
| pan.connect(this.gain); | |||||
| node.start(); | |||||
| node.onended = () => { | |||||
| pan.disconnect(); | |||||
| }; | |||||
| this.setTimer(); | |||||
| } | |||||
| } | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,35 @@ | |||||
| import Source from "./Source"; | |||||
| import { context } from "../audio"; | |||||
| export default class LoopingSource extends Source { | |||||
| kind = "Looping"; | |||||
| private source!: AudioBufferSourceNode; | |||||
| private started = false; | |||||
| private running = false; | |||||
| constructor(name: string) { | |||||
| super(name); | |||||
| } | |||||
| public start(): void { | |||||
| this.started = true; | |||||
| } | |||||
| private pickRandom(): void { | |||||
| const index = Math.floor(Math.random() * this.sounds.length); | |||||
| this.source = context.createBufferSource(); | |||||
| this.source.buffer = this.sounds[index]; | |||||
| this.source.connect(this.gain); | |||||
| this.source.onended = () => { | |||||
| this.pickRandom(); | |||||
| this.source.start(); | |||||
| }; | |||||
| } | |||||
| public tick(dt: number): void { | |||||
| super.tick(dt); | |||||
| if (this.started && this.sounds.length > 0 && !this.running) { | |||||
| this.pickRandom(); | |||||
| this.source.start(); | |||||
| this.running = true; | |||||
| } | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,46 @@ | |||||
| import { Node, context, exposedNumber, loadAudio } from "../audio"; | |||||
| export default abstract class Source extends Node { | |||||
| public abstract kind: string; | |||||
| protected sounds: Array<AudioBuffer> = []; | |||||
| public gain: GainNode; | |||||
| public output: GainNode; | |||||
| public _active = true; | |||||
| get active(): boolean { | |||||
| return this._active; | |||||
| } | |||||
| set active(value: boolean) { | |||||
| this._active = value; | |||||
| this.output.gain.linearRampToValueAtTime( | |||||
| value ? 1.0 : 0.0, | |||||
| context.currentTime + 0.5 | |||||
| ); | |||||
| } | |||||
| @exposedNumber("Volume", 0, 1) | |||||
| public volume = 1; | |||||
| constructor(name: string) { | |||||
| super(name); | |||||
| this.gain = context.createGain(); | |||||
| this.output = context.createGain(); | |||||
| this.gain.connect(this.output); | |||||
| } | |||||
| public loadSound(name: string): void { | |||||
| loadAudio(name, this); | |||||
| } | |||||
| public addLoadedSound(sound: AudioBuffer): void { | |||||
| this.sounds.push(sound); | |||||
| } | |||||
| public abstract start(): void; | |||||
| public tick(dt: number): void { | |||||
| this.gain.gain.value = this.volume; | |||||
| } | |||||
| } | |||||