| @@ -5,12 +5,10 @@ | |||
| <script lang="ts"> | |||
| import { Options, Vue } from "vue-class-component"; | |||
| import VoreAudio from "./components/VoreAudio.vue"; | |||
| import SourceNode from "./components/sources/SourceNode.vue"; | |||
| @Options({ | |||
| components: { | |||
| VoreAudio, | |||
| SourceNode, | |||
| }, | |||
| }) | |||
| export default class App extends Vue {} | |||
| @@ -1,4 +1,5 @@ | |||
| import "reflect-metadata"; | |||
| import Source from "./sources/Source"; | |||
| export abstract class Node { | |||
| constructor(public name: string) {} | |||
| @@ -18,7 +19,8 @@ export type RangeMetadata = { | |||
| 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, { | |||
| name: name, | |||
| min: min, | |||
| @@ -28,7 +30,8 @@ function exposedNumber(name: string, min: number, max: number) { | |||
| 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, { | |||
| name: name, | |||
| 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/"; | |||
| @@ -189,7 +48,7 @@ const audioDict: Map<string, AudioBuffer> = new Map(); | |||
| // 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? | |||
| if (!waiting.has(name)) { | |||
| @@ -7,7 +7,7 @@ | |||
| > | |||
| {{ metadata.name }} | |||
| <Slider | |||
| v-model="source[metadata.key]" | |||
| v-model="node[metadata.key]" | |||
| :min="metadata.min" | |||
| :max="metadata.max" | |||
| :step="-1" | |||
| @@ -17,7 +17,7 @@ | |||
| <div class="node-prop" v-for="(metadata, index) in rangeProps" :key="index"> | |||
| {{ metadata.name }} | |||
| <Slider | |||
| v-model="source[metadata.key]" | |||
| v-model="node[metadata.key]" | |||
| :min="metadata.min" | |||
| :max="metadata.max" | |||
| :step="-1" | |||
| @@ -33,31 +33,31 @@ import { | |||
| exposedRangeMetadata, | |||
| NumberMetadata, | |||
| RangeMetadata, | |||
| Source, | |||
| } from "@/audio"; | |||
| import { Options, Vue } from "vue-class-component"; | |||
| import Slider from "@vueform/slider"; | |||
| import { Node } from "@/audio"; | |||
| @Options({ | |||
| props: { | |||
| source: Source, | |||
| node: Node, | |||
| }, | |||
| components: { | |||
| Slider, | |||
| }, | |||
| }) | |||
| export default class NodeProps extends Vue { | |||
| source!: Source; | |||
| node!: Node; | |||
| numberProps: Array<{ name: string; key: string; min: number; max: number }> = | |||
| []; | |||
| rangeProps: Array<{ name: string; key: string; min: number; max: number }> = | |||
| []; | |||
| mounted(): void { | |||
| Object.keys(this.source).forEach((key) => { | |||
| Object.keys(this.node).forEach((key) => { | |||
| const metadata: NumberMetadata | undefined = Reflect.getMetadata( | |||
| exposedMetadataNumber, | |||
| this.source, | |||
| this.node, | |||
| 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( | |||
| exposedRangeMetadata, | |||
| this.source, | |||
| this.node, | |||
| key | |||
| ); | |||
| @@ -87,9 +87,6 @@ export default class NodeProps extends Vue { | |||
| }); | |||
| } | |||
| }); | |||
| console.log(this.numberProps[0]); | |||
| console.log(this.rangeProps[0]); | |||
| } | |||
| } | |||
| </script> | |||
| @@ -9,7 +9,7 @@ | |||
| <div> | |||
| Many sounds by <a href="https://www.furaffinity.net/user/jeschke">Jit</a>! | |||
| </div> | |||
| <button v-on:click="start">Start</button> | |||
| <button v-on:click="start" class="start-button" v-if="!started">Start</button> | |||
| <div class="soundscape"> | |||
| <source-node | |||
| v-for="(source, index) in sources" | |||
| @@ -17,6 +17,12 @@ | |||
| :source="source" | |||
| > | |||
| </source-node> | |||
| <filter-node | |||
| v-for="(filter, index) in filters" | |||
| :key="index" | |||
| :filter="filter" | |||
| > | |||
| </filter-node> | |||
| </div> | |||
| <div></div> | |||
| @@ -24,15 +30,15 @@ | |||
| </template> | |||
| <script lang="ts"> | |||
| import { | |||
| clearCache, | |||
| IntervalSource, | |||
| LoopingSource, | |||
| setup, | |||
| Source, | |||
| } from "@/audio"; | |||
| import { clearCache, setup } from "@/audio"; | |||
| 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({ | |||
| props: { | |||
| @@ -40,11 +46,37 @@ import SourceNode from "./sources/SourceNode.vue"; | |||
| }, | |||
| components: { | |||
| SourceNode, | |||
| FilterNode, | |||
| }, | |||
| }) | |||
| export default class VoreAudio extends Vue { | |||
| started = false; | |||
| context!: AudioContext; | |||
| 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 { | |||
| 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("bowels-churn-safe.ogg"); | |||
| source.loadSound("bowels-churn-danger.ogg"); | |||
| source.output.connect(this.context.destination); | |||
| source.start(); | |||
| setInterval(() => source.tick(100), 100); | |||
| source.active = false; | |||
| this.sources.push(source); | |||
| this.addSource(source); | |||
| } | |||
| startDigestion(): void { | |||
| @@ -71,12 +99,8 @@ export default class VoreAudio extends Vue { | |||
| source.loadSound("fen-stomach.ogg"); | |||
| source.loadSound("fen-intestines.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 { | |||
| @@ -97,14 +121,10 @@ export default class VoreAudio extends Vue { | |||
| source.loadSound("belch (14).ogg"); | |||
| source.loadSound("belch (15).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; | |||
| this.sources.push(source); | |||
| this.addSource(source); | |||
| } | |||
| startGurgles(): void { | |||
| @@ -130,13 +150,8 @@ export default class VoreAudio extends Vue { | |||
| source.loadSound("gurgles/gurgle (19).ogg"); | |||
| source.loadSound("gurgles/gurgle (20).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 { | |||
| @@ -144,14 +159,29 @@ export default class VoreAudio extends Vue { | |||
| } | |||
| start(): void { | |||
| if (this.started) { | |||
| return; | |||
| } | |||
| this.started = true; | |||
| this.startGlorps(); | |||
| this.startGurgles(); | |||
| this.startDigestion(); | |||
| 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 { | |||
| this.context = setup(); | |||
| this.filterBus = this.context.createGain(); | |||
| this.filterBus.connect(this.context.destination); | |||
| } | |||
| } | |||
| </script> | |||
| @@ -160,12 +190,15 @@ export default class VoreAudio extends Vue { | |||
| .soundscape { | |||
| margin: auto; | |||
| padding: 20px; | |||
| width: 50vw; | |||
| min-width: 1000px; | |||
| width: minmax(50vw, 1500px); | |||
| height: 100%; | |||
| display: grid; | |||
| grid-template-columns: 1fr 1fr; | |||
| grid-template-columns: repeat(auto-fit, minmax(400px, 1fr)); | |||
| grid-auto-rows: 200px; | |||
| grid-gap: 20px; | |||
| } | |||
| .start-button { | |||
| font-size: 60pt; | |||
| } | |||
| </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"> | |||
| <Toggle class="active-toggle" v-model="source.active" /> | |||
| <div class="node-name">{{ source.name }}</div> | |||
| <node-props :source="source"></node-props> | |||
| <node-props :node="source"></node-props> | |||
| </div> | |||
| </template> | |||
| <script lang="ts"> | |||
| import { Source } from "@/audio"; | |||
| import Source from "@/sources/Source"; | |||
| import { Options, Vue } from "vue-class-component"; | |||
| import NodeProps from "@/components/NodeProps.vue"; | |||
| import Toggle from "@vueform/toggle"; | |||
| @@ -15,7 +15,7 @@ import Toggle from "@vueform/toggle"; | |||
| @Options({ | |||
| props: { | |||
| source: Source, | |||
| }, | |||
| }, | |||
| components: { | |||
| NodeProps, | |||
| 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; | |||
| } | |||
| } | |||