| @@ -8,9 +8,13 @@ | |||||
| "lint": "vue-cli-service lint" | "lint": "vue-cli-service lint" | ||||
| }, | }, | ||||
| "dependencies": { | "dependencies": { | ||||
| "@vueform/slider": "^2.0.5", | |||||
| "core-js": "^3.6.5", | "core-js": "^3.6.5", | ||||
| "postcss": "^8.3.6", | |||||
| "reflect-metadata": "^0.1.13", | |||||
| "vue": "^3.0.0", | "vue": "^3.0.0", | ||||
| "vue-class-component": "^8.0.0-0" | |||||
| "vue-class-component": "^8.0.0-0", | |||||
| "vue-slider-component": "^3.2.14" | |||||
| }, | }, | ||||
| "devDependencies": { | "devDependencies": { | ||||
| "@typescript-eslint/eslint-plugin": "^4.18.0", | "@typescript-eslint/eslint-plugin": "^4.18.0", | ||||
| @@ -5,22 +5,30 @@ | |||||
| <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 {} | ||||
| </script> | </script> | ||||
| <style> | <style> | ||||
| body { | |||||
| background: #111; | |||||
| } | |||||
| #app { | #app { | ||||
| font-family: Avenir, Helvetica, Arial, sans-serif; | font-family: Avenir, Helvetica, Arial, sans-serif; | ||||
| -webkit-font-smoothing: antialiased; | -webkit-font-smoothing: antialiased; | ||||
| -moz-osx-font-smoothing: grayscale; | -moz-osx-font-smoothing: grayscale; | ||||
| text-align: center; | text-align: center; | ||||
| color: #2c3e50; | |||||
| color: #ddd; | |||||
| background: #111; | |||||
| margin-top: 60px; | margin-top: 60px; | ||||
| } | } | ||||
| </style> | </style> | ||||
| <style src="@vueform/slider/themes/default.css"></style> | |||||
| @@ -1,12 +1,57 @@ | |||||
| export abstract class Source { | |||||
| import "reflect-metadata"; | |||||
| export abstract class Node { | |||||
| constructor(public name: string) {} | |||||
| } | |||||
| export type NumberMetadata = { | |||||
| name: string; | |||||
| min: number; | |||||
| max: number; | |||||
| }; | |||||
| export type RangeMetadata = { | |||||
| name: string; | |||||
| min: number; | |||||
| max: number; | |||||
| }; | |||||
| export const exposedMetadataNumber = Symbol("exposedNumber"); | |||||
| function exposedNumber(name: string, min: number, max: number) { | |||||
| return Reflect.metadata(exposedMetadataNumber, { | |||||
| name: name, | |||||
| min: min, | |||||
| max: max, | |||||
| }); | |||||
| } | |||||
| export const exposedRangeMetadata = Symbol("exposedRange"); | |||||
| function exposedRange(name: string, min: number, max: number) { | |||||
| return Reflect.metadata(exposedRangeMetadata, { | |||||
| name: name, | |||||
| min: min, | |||||
| max: max, | |||||
| }); | |||||
| } | |||||
| export abstract class Source extends Node { | |||||
| public abstract kind: string; | public abstract kind: string; | ||||
| protected sounds: Array<AudioBuffer> = []; | protected sounds: Array<AudioBuffer> = []; | ||||
| public gain: GainNode; | public gain: GainNode; | ||||
| public output: StereoPannerNode; | |||||
| public output: GainNode; | |||||
| @exposedNumber("Volume", 0, 1) | |||||
| public volume = 1; | |||||
| constructor(public name: string) { | |||||
| @exposedRange("Panning", -1, 1) | |||||
| public panning: [number, number] = [-0.2, 0.2]; | |||||
| constructor(name: string) { | |||||
| super(name); | |||||
| this.gain = context.createGain(); | this.gain = context.createGain(); | ||||
| this.output = context.createStereoPanner(); | |||||
| this.output = context.createGain(); | |||||
| this.gain.connect(this.output); | this.gain.connect(this.output); | ||||
| } | } | ||||
| @@ -20,17 +65,37 @@ export abstract class Source { | |||||
| public abstract start(): void; | public abstract start(): void; | ||||
| public abstract tick(dt: number): void; | |||||
| public tick(dt: number): void { | |||||
| this.gain.gain.value = this.volume; | |||||
| } | |||||
| } | } | ||||
| export class IntervalSource extends Source { | export class IntervalSource extends Source { | ||||
| kind = "Interval"; | kind = "Interval"; | ||||
| private remaining: number; | |||||
| @exposedRange("Interval", 0.25, 30) | |||||
| public interval: [number, number] = [1, 5]; | |||||
| private remaining = 0; | |||||
| private started = false; | private started = false; | ||||
| constructor(name: string, public interval: number, public randomness = 0) { | |||||
| constructor( | |||||
| name: string, | |||||
| minTime: number, | |||||
| maxTime: number, | |||||
| public randomness = 0 | |||||
| ) { | |||||
| super(name); | super(name); | ||||
| this.remaining = this.interval + (Math.random() - 0.5) * 2 * this.randomness | |||||
| 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 { | public start(): void { | ||||
| @@ -38,22 +103,33 @@ export class IntervalSource extends Source { | |||||
| } | } | ||||
| public tick(dt: number): void { | public tick(dt: number): void { | ||||
| super.tick(dt); | |||||
| if (this.started) { | if (this.started) { | ||||
| this.remaining -= dt; | this.remaining -= dt; | ||||
| if (this.remaining <= 0) { | if (this.remaining <= 0) { | ||||
| const index = Math.floor(Math.random() * this.sounds.length); | const index = Math.floor(Math.random() * this.sounds.length); | ||||
| const node = context.createBufferSource(); | const node = context.createBufferSource(); | ||||
| node.buffer = this.sounds[index]; | node.buffer = this.sounds[index]; | ||||
| node.connect(this.gain); | |||||
| 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.start(); | ||||
| this.output.pan.value = Math.random() * 0.4 - 0.2; | |||||
| this.remaining = this.interval + (Math.random() - 0.5) * 2 * this.randomness | |||||
| node.onended = () => { | |||||
| pan.disconnect(); | |||||
| }; | |||||
| this.setTimer(); | |||||
| } | } | ||||
| } | } | ||||
| } | } | ||||
| } | } | ||||
| @@ -69,15 +145,19 @@ export class LoopingSource extends Source { | |||||
| public start(): void { | public start(): void { | ||||
| this.started = true; | this.started = true; | ||||
| } | } | ||||
| private pickRandom(): void { | private pickRandom(): void { | ||||
| const index = Math.floor(Math.random() * this.sounds.length); | const index = Math.floor(Math.random() * this.sounds.length); | ||||
| this.source = context.createBufferSource(); | this.source = context.createBufferSource(); | ||||
| this.source.buffer = this.sounds[index]; | this.source.buffer = this.sounds[index]; | ||||
| this.source.connect(this.gain); | this.source.connect(this.gain); | ||||
| this.source.onended = () => { this.pickRandom(); this.source.start(); }; | |||||
| this.source.onended = () => { | |||||
| this.pickRandom(); | |||||
| this.source.start(); | |||||
| }; | |||||
| } | } | ||||
| public tick(dt: number) { | |||||
| public tick(dt: number): void { | |||||
| super.tick(dt); | |||||
| if (this.started && this.sounds.length > 0 && !this.running) { | if (this.started && this.sounds.length > 0 && !this.running) { | ||||
| this.pickRandom(); | this.pickRandom(); | ||||
| this.source.start(); | this.source.start(); | ||||
| @@ -0,0 +1,102 @@ | |||||
| <template> | |||||
| <div class="node-props"> | |||||
| <div | |||||
| class="node-prop" | |||||
| v-for="(metadata, index) in numberProps" | |||||
| :key="index" | |||||
| > | |||||
| {{ metadata.name }} | |||||
| <Slider | |||||
| v-model="source[metadata.key]" | |||||
| :min="metadata.min" | |||||
| :max="metadata.max" | |||||
| :step="-1" | |||||
| :showTooltip="'drag'" | |||||
| /> | |||||
| </div> | |||||
| <div class="node-prop" v-for="(metadata, index) in rangeProps" :key="index"> | |||||
| {{ metadata.name }} | |||||
| <Slider | |||||
| v-model="source[metadata.key]" | |||||
| :min="metadata.min" | |||||
| :max="metadata.max" | |||||
| :step="-1" | |||||
| :showTooltip="'drag'" | |||||
| /> | |||||
| </div> | |||||
| </div> | |||||
| </template> | |||||
| <script lang="ts"> | |||||
| import { | |||||
| exposedMetadataNumber, | |||||
| exposedRangeMetadata, | |||||
| NumberMetadata, | |||||
| RangeMetadata, | |||||
| Source, | |||||
| } from "@/audio"; | |||||
| import { Options, Vue } from "vue-class-component"; | |||||
| import Slider from "@vueform/slider"; | |||||
| @Options({ | |||||
| props: { | |||||
| source: Source, | |||||
| }, | |||||
| components: { | |||||
| Slider, | |||||
| }, | |||||
| }) | |||||
| export default class NodeProps extends Vue { | |||||
| source!: Source; | |||||
| 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) => { | |||||
| const metadata: NumberMetadata | undefined = Reflect.getMetadata( | |||||
| exposedMetadataNumber, | |||||
| this.source, | |||||
| key | |||||
| ); | |||||
| if (metadata !== undefined) { | |||||
| this.numberProps.push({ | |||||
| name: metadata.name, | |||||
| key: key, | |||||
| min: metadata.min, | |||||
| max: metadata.max, | |||||
| }); | |||||
| } | |||||
| }); | |||||
| Object.keys(this.source).forEach((key) => { | |||||
| const metadata: RangeMetadata | undefined = Reflect.getMetadata( | |||||
| exposedRangeMetadata, | |||||
| this.source, | |||||
| key | |||||
| ); | |||||
| if (metadata !== undefined) { | |||||
| this.rangeProps.push({ | |||||
| name: metadata.name, | |||||
| key: key, | |||||
| min: metadata.min, | |||||
| max: metadata.max, | |||||
| }); | |||||
| } | |||||
| }); | |||||
| console.log(this.numberProps[0]); | |||||
| console.log(this.rangeProps[0]); | |||||
| } | |||||
| } | |||||
| </script> | |||||
| <style scoped> | |||||
| .node-prop { | |||||
| margin: 20px; | |||||
| user-select: none; | |||||
| } | |||||
| </style> | |||||
| @@ -3,30 +3,54 @@ | |||||
| <div>This is a mega-early-alpha vore audio generator.</div> | <div>This is a mega-early-alpha vore audio generator.</div> | ||||
| <div>Click the buttons below to start each kind of audio.</div> | <div>Click the buttons below to start each kind of audio.</div> | ||||
| <div>(clicking repeatedly will play even MORE audio)</div> | <div>(clicking repeatedly will play even MORE audio)</div> | ||||
| <div>Follow <a href="https://twitter.com/causticcrux">@causticcrux</a> for more.</div> | |||||
| <div>Many sounds by <a href="https://www.furaffinity.net/user/jeschke">Jit</a>!</div> | |||||
| <div> | |||||
| Follow <a href="https://twitter.com/causticcrux">@causticcrux</a> for more. | |||||
| </div> | |||||
| <div> | |||||
| Many sounds by <a href="https://www.furaffinity.net/user/jeschke">Jit</a>! | |||||
| </div> | |||||
| <button v-on:click="startGlorps">Glorps</button> | <button v-on:click="startGlorps">Glorps</button> | ||||
| <button v-on:click="startDigestion">Digestion</button> | <button v-on:click="startDigestion">Digestion</button> | ||||
| <button v-on:click="startBurps">Burps</button> | <button v-on:click="startBurps">Burps</button> | ||||
| <button v-on:click="startGurgles">Gurgles</button> | <button v-on:click="startGurgles">Gurgles</button> | ||||
| <div class="soundscape"> | |||||
| <source-node | |||||
| v-for="(source, index) in sources" | |||||
| :key="index" | |||||
| :source="source" | |||||
| > | |||||
| </source-node> | |||||
| </div> | |||||
| <div></div> | <div></div> | ||||
| <button v-on:click="clear">Delete all cached sound (if it gets stuck)</button> | <button v-on:click="clear">Delete all cached sound (if it gets stuck)</button> | ||||
| </template> | </template> | ||||
| <script lang="ts"> | <script lang="ts"> | ||||
| import { clearCache, IntervalSource, LoopingSource, setup, Source } from "@/audio"; | |||||
| import { | |||||
| clearCache, | |||||
| IntervalSource, | |||||
| LoopingSource, | |||||
| setup, | |||||
| Source, | |||||
| } from "@/audio"; | |||||
| import { Options, Vue } from "vue-class-component"; | import { Options, Vue } from "vue-class-component"; | ||||
| import SourceNode from "./sources/SourceNode.vue"; | |||||
| @Options({ | @Options({ | ||||
| props: { | props: { | ||||
| msg: String, | msg: String, | ||||
| }, | }, | ||||
| components: { | |||||
| SourceNode, | |||||
| }, | |||||
| }) | }) | ||||
| export default class VoreAudio extends Vue { | export default class VoreAudio extends Vue { | ||||
| context!: AudioContext; | context!: AudioContext; | ||||
| sources: Array<Source> = []; | |||||
| startGlorps(): void { | startGlorps(): void { | ||||
| const source: Source = new IntervalSource("Guts", 5000, 2000); | |||||
| const source: Source = new IntervalSource("Guts", 5, 8); | |||||
| source.loadSound("bowels-to-intestines.ogg"); | source.loadSound("bowels-to-intestines.ogg"); | ||||
| source.loadSound("intestines-to-bowels.ogg"); | source.loadSound("intestines-to-bowels.ogg"); | ||||
| source.loadSound("intestines-to-stomach.ogg"); | source.loadSound("intestines-to-stomach.ogg"); | ||||
| @@ -37,9 +61,10 @@ export default class VoreAudio extends Vue { | |||||
| 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.output.connect(this.context.destination); | ||||
| console.log(source) | |||||
| source.start(); | source.start(); | ||||
| setInterval(() => source.tick(100), 100); | setInterval(() => source.tick(100), 100); | ||||
| this.sources.push(source); | |||||
| } | } | ||||
| startDigestion(): void { | startDigestion(): void { | ||||
| @@ -51,10 +76,12 @@ export default class VoreAudio extends Vue { | |||||
| source.start(); | source.start(); | ||||
| console.log(source); | console.log(source); | ||||
| setInterval(() => source.tick(100), 100); | setInterval(() => source.tick(100), 100); | ||||
| this.sources.push(source); | |||||
| } | } | ||||
| startBurps(): void { | startBurps(): void { | ||||
| const source: Source = new IntervalSource("Burps", 12000, 3000); | |||||
| const source: Source = new IntervalSource("Burps", 5, 15); | |||||
| source.loadSound("belch (1).ogg"); | source.loadSound("belch (1).ogg"); | ||||
| source.loadSound("belch (2).ogg"); | source.loadSound("belch (2).ogg"); | ||||
| source.loadSound("belch (3).ogg"); | source.loadSound("belch (3).ogg"); | ||||
| @@ -75,10 +102,12 @@ export default class VoreAudio extends Vue { | |||||
| source.start(); | source.start(); | ||||
| console.log(source); | console.log(source); | ||||
| setInterval(() => source.tick(100), 100); | setInterval(() => source.tick(100), 100); | ||||
| this.sources.push(source); | |||||
| } | } | ||||
| startGurgles(): void { | startGurgles(): void { | ||||
| const source: Source = new IntervalSource("Gurgles", 3000, 1000); | |||||
| const source: Source = new IntervalSource("Gurgles", 3, 10); | |||||
| source.loadSound("gurgles/gurgle (1).ogg"); | source.loadSound("gurgles/gurgle (1).ogg"); | ||||
| source.loadSound("gurgles/gurgle (2).ogg"); | source.loadSound("gurgles/gurgle (2).ogg"); | ||||
| source.loadSound("gurgles/gurgle (3).ogg"); | source.loadSound("gurgles/gurgle (3).ogg"); | ||||
| @@ -105,9 +134,11 @@ export default class VoreAudio extends Vue { | |||||
| source.gain.gain.value = 0.5; | source.gain.gain.value = 0.5; | ||||
| console.log(source); | console.log(source); | ||||
| setInterval(() => source.tick(100), 100); | setInterval(() => source.tick(100), 100); | ||||
| this.sources.push(source); | |||||
| } | } | ||||
| clear() { | |||||
| clear(): void { | |||||
| clearCache(); | clearCache(); | ||||
| } | } | ||||
| @@ -118,18 +149,15 @@ export default class VoreAudio extends Vue { | |||||
| </script> | </script> | ||||
| <style scoped> | <style scoped> | ||||
| h3 { | |||||
| margin: 40px 0 0; | |||||
| } | |||||
| ul { | |||||
| list-style-type: none; | |||||
| padding: 0; | |||||
| } | |||||
| li { | |||||
| display: inline-block; | |||||
| margin: 0 10px; | |||||
| } | |||||
| a { | |||||
| color: #42b983; | |||||
| .soundscape { | |||||
| margin: auto; | |||||
| padding: 20px; | |||||
| width: 50vw; | |||||
| min-width: 1000px; | |||||
| height: 100%; | |||||
| display: grid; | |||||
| grid-template-columns: 1fr 1fr; | |||||
| grid-auto-rows: 200px; | |||||
| grid-gap: 20px; | |||||
| } | } | ||||
| </style> | </style> | ||||
| @@ -0,0 +1,45 @@ | |||||
| <template> | |||||
| <div class="source-node"> | |||||
| <div class="node-name">{{ source.name }}</div> | |||||
| <node-props :source="source"></node-props> | |||||
| </div> | |||||
| </template> | |||||
| <script lang="ts"> | |||||
| import { Source } from "@/audio"; | |||||
| import { Options, Vue } from "vue-class-component"; | |||||
| import NodeProps from "@/components/NodeProps.vue"; | |||||
| @Options({ | |||||
| props: { | |||||
| source: Source, | |||||
| }, | |||||
| components: { | |||||
| NodeProps, | |||||
| }, | |||||
| }) | |||||
| export default class SourceNode extends Vue { | |||||
| source!: Source; | |||||
| } | |||||
| </script> | |||||
| <style scoped> | |||||
| .source-node { | |||||
| width: 100%; | |||||
| height: 100%; | |||||
| background: gray; | |||||
| display: flex; | |||||
| flex-direction: column; | |||||
| } | |||||
| .node-name { | |||||
| font-size: 24pt; | |||||
| margin: 4pt; | |||||
| color: #fcf; | |||||
| } | |||||
| .node-properties { | |||||
| display: flex; | |||||
| flex-direction: column; | |||||
| } | |||||
| </style> | |||||