| @@ -61,7 +61,9 @@ export class Soundscape { | |||
| this.filterBus.connect(this.output); | |||
| } | |||
| } | |||
| export abstract class Node { | |||
| abstract kind: string; | |||
| constructor(public name: string) {} | |||
| } | |||
| @@ -85,6 +87,8 @@ export type RangeMetadata = PropMetadata & { | |||
| unmap?: (value: number) => number; | |||
| }; | |||
| export type SoundSetMetadata = PropMetadata; | |||
| export const exposedMetadataNumber = Symbol("exposedNumber"); | |||
| // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types | |||
| @@ -99,6 +103,13 @@ export function exposedRange(options: RangeMetadata) { | |||
| return Reflect.metadata(exposedRangeMetadata, options); | |||
| } | |||
| export const exposedSoundSetMetadata = Symbol("exposedSoundSet"); | |||
| // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types | |||
| export function exposedSoundSet(options: SoundSetMetadata) { | |||
| return Reflect.metadata(exposedSoundSetMetadata, options); | |||
| } | |||
| export let context: AudioContext; | |||
| const audioBaseUrl = "/audio/"; | |||
| @@ -255,19 +266,22 @@ export function clearCache(): void { | |||
| // if the indexedDB table doesn't exist at all, make it | |||
| function createCache(): void { | |||
| const idb = window.indexedDB; | |||
| const req = idb.open("cache", 1); | |||
| console.log("Create cache"); | |||
| req.onupgradeneeded = (event) => { | |||
| const db = req.result; | |||
| if (event.oldVersion > 0 && event.oldVersion < 3) { | |||
| console.log("Version change"); | |||
| if (event.oldVersion > 0) { | |||
| db.deleteObjectStore("audio"); | |||
| } | |||
| db.createObjectStore("audio", { keyPath: ["name"] }); | |||
| }; | |||
| console.log(req); | |||
| req.onerror = () => { | |||
| alert("Couldn't open the database?"); | |||
| }; | |||
| @@ -1,6 +1,6 @@ | |||
| <template> | |||
| <div class="draggable" draggable="true" v-on:dragstart="dragstart"> | |||
| <div class="label">{{ label }}</div> | |||
| <div class="label">{{ node.name }}</div> | |||
| </div> | |||
| </template> | |||
| @@ -9,15 +9,14 @@ import { Options, Vue } from "vue-class-component"; | |||
| @Options({ | |||
| props: { | |||
| label: String, | |||
| node: { name: String, kind: String }, | |||
| }, | |||
| }) | |||
| export default class Draggable extends Vue { | |||
| label!: string; | |||
| node!: { name: string; kind: string }; | |||
| dragstart(event: DragEvent): void { | |||
| console.log(event?.dataTransfer); | |||
| event?.dataTransfer?.setData("text/plain", this.label); | |||
| event?.dataTransfer?.setData(this.node.kind, JSON.stringify(this.node)); | |||
| } | |||
| } | |||
| </script> | |||
| @@ -3,9 +3,17 @@ | |||
| <div class="list-label">Sounds</div> | |||
| <div class="list"> | |||
| <draggable | |||
| v-for="(source, index) in soundSets" | |||
| v-for="(source, index) in presetSources" | |||
| :key="index" | |||
| :label="source.name" | |||
| :node="source" | |||
| /> | |||
| </div> | |||
| <div class="list-label">Filters</div> | |||
| <div class="list"> | |||
| <draggable | |||
| v-for="(source, index) in presetFilters" | |||
| :key="index" | |||
| :node="source" | |||
| /> | |||
| </div> | |||
| </div> | |||
| @@ -14,8 +22,7 @@ | |||
| <script lang="ts"> | |||
| import { Options, Vue } from "vue-class-component"; | |||
| import Draggable from "@/components/Draggable.vue"; | |||
| import * as SoundSets from "@/data/sound-sets"; | |||
| import { SoundSet } from "@/sources/Source"; | |||
| import { PresetSources, PresetFilters } from "@/data/presets"; | |||
| @Options({ | |||
| components: { | |||
| @@ -23,7 +30,8 @@ import { SoundSet } from "@/sources/Source"; | |||
| }, | |||
| }) | |||
| export default class Menu extends Vue { | |||
| soundSets: Array<SoundSet> = Array.from(Object.values(SoundSets)); | |||
| presetSources: Array<{ name: string; kind: "Source" }> = PresetSources; | |||
| presetFilters: Array<{ name: string }> = PresetFilters; | |||
| } | |||
| </script> | |||
| @@ -8,8 +8,8 @@ | |||
| > | |||
| </source-node> | |||
| <source-node | |||
| v-on:drop="drop" | |||
| v-on:dragover="drag" | |||
| v-on:drop="dropSource" | |||
| v-on:dragover="dragSource" | |||
| :dummy="true" | |||
| ></source-node> | |||
| <filter-node | |||
| @@ -18,6 +18,11 @@ | |||
| :filter="filter" | |||
| > | |||
| </filter-node> | |||
| <filter-node | |||
| v-on:drop="dropFilter" | |||
| v-on:dragover="dragFilter" | |||
| :dummy="true" | |||
| ></filter-node> | |||
| </div> | |||
| <div></div> | |||
| </template> | |||
| @@ -27,10 +32,9 @@ import { Soundscape } from "@/audio"; | |||
| import { Options, Vue } from "vue-class-component"; | |||
| import SourceNode from "./nodes/SourceNode.vue"; | |||
| import FilterNode from "./nodes/FilterNode.vue"; | |||
| import * as SoundSets from "@/data/sound-sets"; | |||
| import { SoundSet, Source } from "@/sources/Source"; | |||
| import { IntervalSource } from "@/sources/IntervalSource"; | |||
| import { LoopingSource } from "@/sources/LoopingSource"; | |||
| import { Source } from "@/sources/Source"; | |||
| import { deserializeNode } from "@/serialize"; | |||
| import { Filter } from "@/filters/Filter"; | |||
| @Options({ | |||
| props: { | |||
| @@ -45,28 +49,38 @@ export default class SoundscapeComp extends Vue { | |||
| soundscape!: Soundscape; | |||
| started = false; | |||
| context!: AudioContext; | |||
| sources: { [key: string]: new (name: string) => Source } = { | |||
| IntervalSource: IntervalSource, | |||
| LoopingSource: LoopingSource, | |||
| }; | |||
| soundSets: { [key: string]: SoundSet } = SoundSets; | |||
| drag(ev: DragEvent): void { | |||
| ev.preventDefault(); | |||
| dragSource(event: DragEvent): void { | |||
| if (event.dataTransfer) { | |||
| if (event.dataTransfer.types.includes("source")) event.preventDefault(); | |||
| } | |||
| } | |||
| drop(event: DragEvent): void { | |||
| dropSource(event: DragEvent): void { | |||
| event.preventDefault(); | |||
| if (event.dataTransfer) { | |||
| const label = event.dataTransfer.getData("text/plain"); | |||
| const data = event.dataTransfer.getData("source"); | |||
| const node = deserializeNode(JSON.parse(data)); | |||
| const soundSet = this.soundSets[label]; | |||
| this.soundscape.addSource(node as Source); | |||
| } | |||
| } | |||
| const source = new this.sources[soundSet.defaultSource](soundSet.name); | |||
| source.soundSet = soundSet; | |||
| this.soundscape.addSource(source); | |||
| // TODO | |||
| dragFilter(event: DragEvent): void { | |||
| if (event.dataTransfer) { | |||
| if (event.dataTransfer.types.includes("filter")) event.preventDefault(); | |||
| } | |||
| } | |||
| dropFilter(event: DragEvent): void { | |||
| event.preventDefault(); | |||
| if (event.dataTransfer) { | |||
| const data = event.dataTransfer.getData("filter"); | |||
| const node = deserializeNode(JSON.parse(data)); | |||
| this.soundscape.addFilter(node as Filter); | |||
| } | |||
| } | |||
| @@ -76,6 +90,8 @@ export default class SoundscapeComp extends Vue { | |||
| mounted(): void { | |||
| this.soundscape.start(); | |||
| console.log(this.soundscape); | |||
| } | |||
| } | |||
| </script> | |||
| @@ -1,8 +1,15 @@ | |||
| <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> | |||
| <div | |||
| v-if="!dummy" | |||
| :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> | |||
| <div v-if="dummy" class="filter-node dummy">Drop filters here!</div> | |||
| </div> | |||
| </template> | |||
| @@ -16,6 +23,7 @@ import Toggle from "@vueform/toggle"; | |||
| @Options({ | |||
| props: { | |||
| filter: Filter, | |||
| dummy: Boolean, | |||
| }, | |||
| components: { | |||
| NodeProps, | |||
| @@ -24,6 +32,7 @@ import Toggle from "@vueform/toggle"; | |||
| }) | |||
| export default class FilterNode extends Vue { | |||
| filter!: Filter; | |||
| dummy = false; | |||
| } | |||
| </script> | |||
| @@ -59,4 +68,8 @@ export default class FilterNode extends Vue { | |||
| top: 10px; | |||
| left: 10px; | |||
| } | |||
| .dummy { | |||
| min-height: 200px; | |||
| } | |||
| </style> | |||
| @@ -10,7 +10,7 @@ | |||
| <div class="node-name">{{ source.name }}</div> | |||
| <node-props :node="source"></node-props> | |||
| </div> | |||
| <div v-if="dummy" class="source-node dummy">Drop here!</div> | |||
| <div v-if="dummy" class="source-node dummy">Drop sounds here!</div> | |||
| </div> | |||
| </template> | |||
| @@ -0,0 +1,63 @@ | |||
| export const PresetSources: Array<{ | |||
| name: string; | |||
| kind: "Source"; | |||
| // eslint-disable-next-line @typescript-eslint/no-explicit-any | |||
| [x: string]: any; | |||
| }> = [ | |||
| { | |||
| soundSet: { | |||
| name: "Gurgles", | |||
| soundKeys: [ | |||
| "gurgles/gurgle (1)", | |||
| "gurgles/gurgle (2)", | |||
| "gurgles/gurgle (3)", | |||
| "gurgles/gurgle (4)", | |||
| "gurgles/gurgle (5)", | |||
| "gurgles/gurgle (6)", | |||
| "gurgles/gurgle (7)", | |||
| "gurgles/gurgle (8)", | |||
| "gurgles/gurgle (9)", | |||
| "gurgles/gurgle (10)", | |||
| "gurgles/gurgle (11)", | |||
| "gurgles/gurgle (12)", | |||
| "gurgles/gurgle (13)", | |||
| "gurgles/gurgle (14)", | |||
| "gurgles/gurgle (15)", | |||
| "gurgles/gurgle (16)", | |||
| "gurgles/gurgle (17)", | |||
| "gurgles/gurgle (18)", | |||
| "gurgles/gurgle (19)", | |||
| "gurgles/gurgle (20)", | |||
| "gurgles/gurgle (21)", | |||
| ], | |||
| }, | |||
| kind: "Source", | |||
| volume: 1, | |||
| interval: [4, 6], | |||
| pitch: [0.9, 1.1], | |||
| panning: [-0.2, 0.2], | |||
| name: "Gurgles", | |||
| type: "IntervalSource", | |||
| }, | |||
| { | |||
| soundSet: { | |||
| name: "Squishing", | |||
| soundKeys: ["squishing"], | |||
| }, | |||
| kind: "Source", | |||
| volume: 1, | |||
| pitch: 1, | |||
| name: "Squishing", | |||
| type: "LoopingSource", | |||
| }, | |||
| ]; | |||
| // eslint-disable-next-line @typescript-eslint/no-explicit-any | |||
| export const PresetFilters: Array<{ name: string; [x: string]: any }> = [ | |||
| { | |||
| cutoff: 1000, | |||
| name: "Lowpass Filter", | |||
| kind: "Filter", | |||
| type: "LowpassFilter", | |||
| }, | |||
| ]; | |||
| @@ -1,15 +0,0 @@ | |||
| import { SoundSet } from "@/sources/Source"; | |||
| export const Gurgles: SoundSet = new SoundSet( | |||
| "Gurgles", | |||
| Array(21) | |||
| .fill(0) | |||
| .map((x, i) => "gurgles/gurgle (" + (i + 1) + ")"), | |||
| "IntervalSource" | |||
| ); | |||
| export const Squishing: SoundSet = new SoundSet( | |||
| "Squishing", | |||
| ["squishing"], | |||
| "LoopingSource" | |||
| ); | |||
| @@ -1,4 +1,22 @@ | |||
| /* eslint-disable @typescript-eslint/no-explicit-any */ | |||
| import { createApp } from "vue"; | |||
| import Dissolve from "./Dissolve.vue"; | |||
| import { LowpassFilter } from "./filters/LowpassFilter"; | |||
| import { | |||
| deserializeNode, | |||
| deserializeSoundscape, | |||
| serializeNode, | |||
| serializeSoundscape, | |||
| } from "./serialize"; | |||
| import { IntervalSource } from "./sources/IntervalSource"; | |||
| import { SoundSet } from "./sources/Source"; | |||
| createApp(Dissolve).mount("#app"); | |||
| (window as any).IntervalSource = IntervalSource; | |||
| (window as any).LowpassFilter = LowpassFilter; | |||
| (window as any).SoundSet = SoundSet; | |||
| (window as any).serializeNode = serializeNode; | |||
| (window as any).deserializeNode = deserializeNode; | |||
| (window as any).serializeSoundscape = serializeSoundscape; | |||
| (window as any).deserializeSoundscape = deserializeSoundscape; | |||
| @@ -0,0 +1,150 @@ | |||
| import { | |||
| exposedMetadataNumber, | |||
| exposedRangeMetadata, | |||
| exposedSoundSetMetadata, | |||
| NumberMetadata, | |||
| RangeMetadata, | |||
| Soundscape, | |||
| SoundSetMetadata, | |||
| } from "./audio"; | |||
| import { IntervalSource } from "./sources/IntervalSource"; | |||
| import { LoopingSource } from "./sources/LoopingSource"; | |||
| import { Node } from "./audio"; | |||
| const constructors: { [key: string]: new (name: string) => Node } = { | |||
| IntervalSource: IntervalSource, | |||
| LoopingSource: LoopingSource, | |||
| LowpassFilter: LowpassFilter, | |||
| HighpassFilter: HighpassFilter, | |||
| SterwoWidthFilter: StereoWidthFilter, | |||
| }; | |||
| import { SoundSet, Source } from "./sources/Source"; | |||
| import { Filter } from "./filters/Filter"; | |||
| import { LowpassFilter } from "./filters/LowpassFilter"; | |||
| import { HighpassFilter } from "./filters/HighpassFilter"; | |||
| import { StereoWidthFilter } from "./filters/StereoWidthFilter"; | |||
| // eslint-disable-next-line @typescript-eslint/no-explicit-any | |||
| export function serializeNode<T extends Node>(_node: T): any { | |||
| // eslint-disable-next-line @typescript-eslint/no-explicit-any | |||
| const results: any = {}; | |||
| // eslint-disable-next-line @typescript-eslint/no-explicit-any | |||
| const node: any = _node; | |||
| Object.keys(node).forEach((key) => { | |||
| const numberMetadata: NumberMetadata | undefined = Reflect.getMetadata( | |||
| exposedMetadataNumber, | |||
| node, | |||
| key | |||
| ); | |||
| if (numberMetadata !== undefined) { | |||
| results[key] = node[key]; | |||
| } | |||
| const rangeMetadata: RangeMetadata | undefined = Reflect.getMetadata( | |||
| exposedRangeMetadata, | |||
| node, | |||
| key | |||
| ); | |||
| if (rangeMetadata !== undefined) { | |||
| results[key] = node[key]; | |||
| } | |||
| const soundSetMetadata: SoundSetMetadata | undefined = Reflect.getMetadata( | |||
| exposedSoundSetMetadata, | |||
| node, | |||
| key | |||
| ); | |||
| if (soundSetMetadata !== undefined) { | |||
| const soundSet = node[key] as SoundSet; | |||
| results[key] = { | |||
| name: soundSet.name, | |||
| soundKeys: soundSet.soundKeys, | |||
| }; | |||
| } | |||
| }); | |||
| results.kind = node.kind; | |||
| results.name = node.name; | |||
| results.type = node.constructor.name; | |||
| return results; | |||
| } | |||
| // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types, @typescript-eslint/no-explicit-any | |||
| export function deserializeNode(data: any): Node { | |||
| const constructor = constructors[data.type]; | |||
| // eslint-disable-next-line @typescript-eslint/no-explicit-any | |||
| const node: any = new constructor(data.name); | |||
| Object.keys(node).forEach((key) => { | |||
| const numberMetadata: NumberMetadata | undefined = Reflect.getMetadata( | |||
| exposedMetadataNumber, | |||
| node, | |||
| key | |||
| ); | |||
| if (numberMetadata !== undefined) { | |||
| node[key] = data[key]; | |||
| } | |||
| const rangeMetadata: RangeMetadata | undefined = Reflect.getMetadata( | |||
| exposedRangeMetadata, | |||
| node, | |||
| key | |||
| ); | |||
| if (rangeMetadata !== undefined) { | |||
| node[key] = data[key]; | |||
| } | |||
| const soundSetMetadata: SoundSetMetadata | undefined = Reflect.getMetadata( | |||
| exposedSoundSetMetadata, | |||
| node, | |||
| key | |||
| ); | |||
| if (soundSetMetadata !== undefined) { | |||
| node[key] = new SoundSet(data[key].name, data[key].soundKeys); | |||
| } | |||
| }); | |||
| return node as Node; | |||
| } | |||
| export type SerializedSoundscape = { | |||
| name: string; | |||
| // eslint-disable-next-line @typescript-eslint/no-explicit-any | |||
| sources: Array<any>; | |||
| // eslint-disable-next-line @typescript-eslint/no-explicit-any | |||
| filters: Array<any>; | |||
| }; | |||
| export function serializeSoundscape(scape: Soundscape): SerializedSoundscape { | |||
| const result = { | |||
| name: scape.name, | |||
| sources: scape.sources.map((source) => serializeNode(source)), | |||
| filters: scape.filters.map((filter) => serializeNode(filter)), | |||
| }; | |||
| return result; | |||
| } | |||
| export function deserializeSoundscape(data: SerializedSoundscape): Soundscape { | |||
| const scape = new Soundscape(); | |||
| scape.name = data.name; | |||
| data.sources | |||
| .map((source) => deserializeNode(source) as Source) | |||
| .forEach((source) => scape.addSource(source)); | |||
| data.filters | |||
| .map((filter) => deserializeNode(filter) as Filter) | |||
| .forEach((filter) => scape.addFilter(filter)); | |||
| return scape; | |||
| } | |||
| @@ -2,8 +2,6 @@ import { Source } from "./Source"; | |||
| import { context, exposedRange } from "../audio"; | |||
| export class IntervalSource extends Source { | |||
| kind = "Interval"; | |||
| @exposedRange({ | |||
| name: "Interval", | |||
| min: 0.25, | |||
| @@ -1,8 +1,13 @@ | |||
| import { Source } from "./Source"; | |||
| import { context, exposedNumber } from "../audio"; | |||
| export type SerializedLoopingSource = { | |||
| name: string; | |||
| volume: number; | |||
| pitch: number; | |||
| }; | |||
| export class LoopingSource extends Source { | |||
| kind = "Looping"; | |||
| private source!: AudioBufferSourceNode; | |||
| private started = false; | |||
| private running = false; | |||
| @@ -21,6 +26,14 @@ export class LoopingSource extends Source { | |||
| super(name); | |||
| } | |||
| static deserialize(info: SerializedLoopingSource): LoopingSource { | |||
| const source = new LoopingSource(info.name); | |||
| source.volume = info.volume; | |||
| source.pitch = info.pitch; | |||
| return source; | |||
| } | |||
| public start(): void { | |||
| this.started = true; | |||
| } | |||
| @@ -1,14 +1,16 @@ | |||
| import { Node, context, exposedNumber, loadAudio } from "../audio"; | |||
| import { | |||
| Node, | |||
| context, | |||
| exposedNumber, | |||
| loadAudio, | |||
| exposedSoundSet, | |||
| } from "../audio"; | |||
| export class SoundSet { | |||
| soundMap: Map<string, AudioBuffer> = new Map(); | |||
| soundList: Array<AudioBuffer> = []; | |||
| constructor( | |||
| public name: string, | |||
| public soundKeys: Array<string>, | |||
| public defaultSource: string | |||
| ) { | |||
| constructor(public name: string, public soundKeys: Array<string>) { | |||
| this.soundKeys.forEach((sound) => { | |||
| loadAudio(sound, this); | |||
| }); | |||
| @@ -21,8 +23,12 @@ export class SoundSet { | |||
| } | |||
| export abstract class Source extends Node { | |||
| public abstract kind: string; | |||
| public soundSet: SoundSet = new SoundSet("Empty", [], "IntervalSource"); | |||
| kind = "Source"; | |||
| @exposedSoundSet({ | |||
| name: "Sounds", | |||
| }) | |||
| public soundSet: SoundSet = new SoundSet("Empty", []); | |||
| public gain: GainNode; | |||
| public output: GainNode; | |||
| public _active = true; | |||