├── .gitignore ├── src ├── shared │ ├── helpers │ │ ├── index.js │ │ ├── field.js │ │ ├── id.js │ │ ├── geometry.js │ │ └── __tests__ │ │ │ └── geometry.js │ ├── core │ │ ├── index.js │ │ ├── icon.js │ │ ├── __tests__ │ │ │ ├── elevation.js │ │ │ ├── typography.js │ │ │ ├── typography.html │ │ │ ├── elevation.html │ │ │ └── __snapshots__ │ │ │ │ └── typography.js.snap │ │ ├── theme.js │ │ ├── typography.js │ │ ├── elevation.js │ │ └── element.js │ ├── node-editor │ │ ├── graph.js │ │ ├── index.js │ │ ├── use-node-editor-mouse-position.js │ │ ├── graph-node-socket.js │ │ ├── use-mouse-navigation.js │ │ ├── graph-node-input.js │ │ ├── graph-node-output.js │ │ ├── use-graph-node-move.js │ │ ├── node-editor.js │ │ ├── graph-node.js │ │ ├── use-graph-link.js │ │ ├── use-graph-node-selection.js │ │ └── graph-link.js │ └── base │ │ ├── index.js │ │ ├── icon-button.js │ │ ├── icon.js │ │ ├── button.js │ │ ├── menu-item.js │ │ ├── number-field.js │ │ ├── tab.js │ │ ├── tooltip.js │ │ ├── fab.js │ │ ├── menu.js │ │ ├── file.js │ │ ├── select.js │ │ └── text-field.js ├── helpers │ ├── index.js │ ├── file-helper.js │ ├── export-file.js │ └── import-file.js ├── core │ ├── index.js │ ├── audio-analyser.js │ ├── waane-app.js │ ├── button-play-pause.js │ ├── __tests__ │ │ └── waane-file.js │ └── audio-editor.js ├── audio-tracker │ ├── index.js │ ├── use-keyboard-navigation.js │ ├── track-effect.js │ ├── audio-track.js │ ├── use-audio-tracker.js │ ├── __tests__ │ │ └── audio-tracker.js │ └── audio-tracker.js ├── audio-node-editor │ ├── use-audio-context.js │ ├── use-node-editor-menu.js │ ├── index.js │ ├── node-audio-destination.js │ ├── use-graph-node-menu.js │ ├── node-white-noise.js │ ├── node-constant.js │ ├── node-gain.js │ ├── node-track.js │ ├── node-oscillator.js │ ├── node-schedule.js │ ├── use-audio-link.js │ ├── node-biquad-filter.js │ ├── __tests__ │ │ ├── audio-link.js │ │ ├── node-track.js │ │ ├── audio-node.js │ │ ├── node-oscillator.js │ │ ├── graph-node.js │ │ └── graph-link.js │ ├── use-audio-link-type.js │ ├── node-audio-file.js │ ├── use-audio-node.js │ └── node-analyser.js ├── index.js ├── index.html └── testing │ ├── bass-drum-0.3.js │ ├── bass-drum-0.1.js │ └── bass-drum-0.2.js ├── README.md ├── .prettierrc ├── .github ├── dependabot.yml └── workflows │ ├── ci.yml │ └── gh-pages.yml ├── .vscode └── settings.json ├── jsconfig.json └── package.json /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | coverage -------------------------------------------------------------------------------- /src/shared/helpers/index.js: -------------------------------------------------------------------------------- 1 | import './field.js' 2 | import './geometry.js' 3 | import './id.js' 4 | -------------------------------------------------------------------------------- /src/helpers/index.js: -------------------------------------------------------------------------------- 1 | import './export-file.js' 2 | import './file-helper.js' 3 | import './import-file.js' 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Web Audio API Node Editor 2 | 3 | Digital Audio Workstation combining a tracker and a node editor. 4 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "semi": false, 4 | "trailingComma": "all", 5 | "printWidth": 120 6 | } 7 | -------------------------------------------------------------------------------- /src/core/index.js: -------------------------------------------------------------------------------- 1 | import './audio-analyser.js' 2 | import './audio-editor.js' 3 | import './button-play-pause.js' 4 | import './waane-app.js' 5 | -------------------------------------------------------------------------------- /src/shared/core/index.js: -------------------------------------------------------------------------------- 1 | import './element.js' 2 | import './elevation.js' 3 | import './icon.js' 4 | import './theme.js' 5 | import './typography.js' 6 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: npm 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | time: "04:00" 8 | open-pull-requests-limit: 10 9 | -------------------------------------------------------------------------------- /src/audio-tracker/index.js: -------------------------------------------------------------------------------- 1 | import './audio-track.js' 2 | import './audio-tracker.js' 3 | import './track-effect.js' 4 | import './use-audio-tracker.js' 5 | import './use-keyboard-navigation.js' 6 | -------------------------------------------------------------------------------- /src/shared/core/icon.js: -------------------------------------------------------------------------------- 1 | const link = document.createElement('link') 2 | link.href = 'https://fonts.googleapis.com/icon?family=Material+Icons' 3 | link.rel = 'stylesheet' 4 | document.head.appendChild(link) 5 | -------------------------------------------------------------------------------- /src/shared/helpers/field.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @param {HTMLElement} element 3 | */ 4 | export function isField(element) { 5 | return element.matches('w-text-field, w-number-field, w-select, w-file, w-button') 6 | } 7 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.defaultFormatter": "esbenp.prettier-vscode", 3 | "editor.codeActionsOnSave": { "source.organizeImports": true }, 4 | "editor.formatOnSave": true, 5 | "editor.tabSize": 2 6 | } 7 | -------------------------------------------------------------------------------- /src/audio-node-editor/use-audio-context.js: -------------------------------------------------------------------------------- 1 | /** @type {AudioContext} */ 2 | let audioContext 3 | 4 | export default function useAudioContext() { 5 | if (!audioContext) { 6 | audioContext = new AudioContext() 7 | } 8 | return audioContext 9 | } 10 | -------------------------------------------------------------------------------- /src/shared/core/__tests__/elevation.js: -------------------------------------------------------------------------------- 1 | import { expect, test } from '@jest/globals' 2 | import elevation from '../elevation' 3 | 4 | for (let z = 0; z < 25; z++) { 5 | test(`returns styles for z=${z}`, () => { 6 | expect(elevation(z)).toMatchSnapshot() 7 | }) 8 | } 9 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import './audio-node-editor/index.js' 2 | import './audio-tracker/index.js' 3 | import './core/index.js' 4 | import './helpers/index.js' 5 | import './shared/base/index.js' 6 | import './shared/core/index.js' 7 | import './shared/helpers/index.js' 8 | import './shared/node-editor/index.js' 9 | -------------------------------------------------------------------------------- /jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "checkJs": true, 4 | "target": "esnext", 5 | "moduleResolution": "node", 6 | "noImplicitAny": true, 7 | "noImplicitReturns": true, 8 | "noImplicitThis": true, 9 | "noUnusedLocals": true, 10 | "noUnusedParameters": true 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/shared/node-editor/graph.js: -------------------------------------------------------------------------------- 1 | import { css, defineCustomElement } from '../core/element.js' 2 | 3 | export default defineCustomElement('w-graph', { 4 | styles: css` 5 | :host { 6 | position: absolute; 7 | top: 50%; 8 | left: 50%; 9 | transform-origin: top left; 10 | } 11 | `, 12 | }) 13 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: push 3 | jobs: 4 | test: 5 | name: Test 6 | runs-on: ubuntu-latest 7 | steps: 8 | - name: Checkout 9 | uses: actions/checkout@main 10 | 11 | - name: Install dependencies 12 | run: yarn 13 | 14 | - name: Test 15 | run: yarn test:ci 16 | -------------------------------------------------------------------------------- /src/shared/base/index.js: -------------------------------------------------------------------------------- 1 | import './button.js' 2 | import './fab.js' 3 | import './file.js' 4 | import './icon-button.js' 5 | import './icon.js' 6 | import './menu-item.js' 7 | import './menu.js' 8 | import './number-field.js' 9 | import './select.js' 10 | import './tab.js' 11 | import './text-field.js' 12 | import './tooltip.js' 13 | -------------------------------------------------------------------------------- /src/shared/core/__tests__/typography.js: -------------------------------------------------------------------------------- 1 | import { expect, test } from '@jest/globals' 2 | import typography, { typescale } from '../typography' 3 | 4 | Object.keys(typescale).forEach((/** @type {keyof typeof typescale} */ name) => { 5 | test(`returns styles for ${name}`, () => { 6 | expect(typography(name)).toMatchSnapshot() 7 | }) 8 | }) 9 | -------------------------------------------------------------------------------- /src/core/audio-analyser.js: -------------------------------------------------------------------------------- 1 | import { css, defineCustomElement, html } from '../shared/core/element.js' 2 | 3 | defineCustomElement('audio-analyser', { 4 | styles: css` 5 | :host { 6 | height: 100%; 7 | display: flex; 8 | } 9 | 10 | canvas { 11 | flex: 1; 12 | } 13 | `, 14 | template: html``, 15 | }) 16 | -------------------------------------------------------------------------------- /src/shared/helpers/id.js: -------------------------------------------------------------------------------- 1 | /** @type {Map} */ 2 | const nextIdsByKey = new Map() 3 | 4 | /** 5 | * @param {string} key 6 | */ 7 | export function nextId(key) { 8 | const id = nextIdsByKey.get(key) || 1 9 | nextIdsByKey.set(key, id + 1) 10 | return id 11 | } 12 | 13 | export function clearAllIds() { 14 | nextIdsByKey.clear() 15 | } 16 | -------------------------------------------------------------------------------- /src/audio-node-editor/use-node-editor-menu.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @typedef {import('../shared/base/menu.js').default} Menu 3 | */ 4 | 5 | /** 6 | * @param {HTMLElement} host 7 | * @param {Menu} menu 8 | */ 9 | export default function useNodeEditorMenu(host, menu) { 10 | host.addEventListener('contextmenu', (event) => { 11 | menu.open = true 12 | menu.x = event.clientX 13 | menu.y = event.clientY 14 | }) 15 | } 16 | -------------------------------------------------------------------------------- /src/shared/node-editor/index.js: -------------------------------------------------------------------------------- 1 | import './graph-link.js' 2 | import './graph-node-input.js' 3 | import './graph-node-output.js' 4 | import './graph-node-socket.js' 5 | import './graph-node.js' 6 | import './graph.js' 7 | import './node-editor.js' 8 | import './use-graph-link.js' 9 | import './use-graph-node-move.js' 10 | import './use-graph-node-selection.js' 11 | import './use-mouse-navigation.js' 12 | import './use-node-editor-mouse-position.js' 13 | -------------------------------------------------------------------------------- /src/core/waane-app.js: -------------------------------------------------------------------------------- 1 | import { defineCustomElement, html } from '../shared/core/element.js' 2 | 3 | export default defineCustomElement('waane-app', { 4 | template: html``, 5 | setup({ host }) { 6 | if (location.pathname.endsWith('/analyser')) { 7 | host.shadowRoot.appendChild(document.createElement('audio-analyser')) 8 | } else { 9 | host.shadowRoot.appendChild(document.createElement('audio-editor')) 10 | } 11 | }, 12 | }) 13 | -------------------------------------------------------------------------------- /src/audio-node-editor/index.js: -------------------------------------------------------------------------------- 1 | import './audio-node-editor.js' 2 | import './node-analyser.js' 3 | import './node-audio-destination.js' 4 | import './node-audio-file.js' 5 | import './node-biquad-filter.js' 6 | import './node-constant.js' 7 | import './node-gain.js' 8 | import './node-oscillator.js' 9 | import './node-schedule.js' 10 | import './node-track.js' 11 | import './node-white-noise.js' 12 | import './use-audio-context.js' 13 | import './use-audio-link-type.js' 14 | import './use-audio-link.js' 15 | import './use-audio-node.js' 16 | import './use-graph-node-menu.js' 17 | import './use-node-editor-menu.js' 18 | -------------------------------------------------------------------------------- /src/shared/node-editor/use-node-editor-mouse-position.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @typedef {import('./node-editor.js').default} NodeEditor 3 | */ 4 | 5 | /** 6 | * @param {NodeEditor} host 7 | */ 8 | export default function useNodeEditorMousePosition(host) { 9 | /** 10 | * @param {MouseEvent} event 11 | */ 12 | function getNodeEditorMousePosition(event) { 13 | const { x, y, width, height } = host.getBoundingClientRect() 14 | return { 15 | x: (event.clientX - x - width / 2) / host.zoom - host.panX, 16 | y: (event.clientY - y - height / 2) / host.zoom - host.panY, 17 | } 18 | } 19 | return getNodeEditorMousePosition 20 | } 21 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "waane", 3 | "version": "1.0.0", 4 | "description": "Digital Audio Workstation combining a tracker and a node editor", 5 | "main": "src/index.js", 6 | "author": "Volcomix", 7 | "license": "MIT", 8 | "type": "module", 9 | "scripts": { 10 | "start": "live-server --entry-file=/ --no-browser src", 11 | "jest": "NODE_OPTIONS=--experimental-vm-modules npx jest --env=jsdom", 12 | "test": "yarn jest --watch", 13 | "test:ci": "yarn jest", 14 | "typecheck": "npx -p typescript tsc -p jsconfig.json" 15 | }, 16 | "devDependencies": { 17 | "@jest/globals": "^27.5.1", 18 | "jest": "^27.5.1", 19 | "live-server": "^1.2.1" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/shared/base/icon-button.js: -------------------------------------------------------------------------------- 1 | import { css, defineCustomElement } from '../core/element.js' 2 | 3 | export default defineCustomElement('w-icon-button', { 4 | styles: css` 5 | :host { 6 | width: 36px; 7 | height: 36px; 8 | border-radius: 50%; 9 | display: flex; 10 | justify-content: center; 11 | align-items: center; 12 | cursor: pointer; 13 | user-select: none; 14 | transition: background-color 100ms var(--easing-standard); 15 | } 16 | 17 | :host(:hover) { 18 | background-color: rgba(var(--color-on-surface) / 0.04); 19 | } 20 | 21 | :host(:active) { 22 | background-color: rgba(var(--color-on-surface) / 0.16); 23 | } 24 | `, 25 | }) 26 | -------------------------------------------------------------------------------- /src/audio-node-editor/node-audio-destination.js: -------------------------------------------------------------------------------- 1 | import { defineCustomElement, html } from '../shared/core/element.js' 2 | import useAudioContext from './use-audio-context.js' 3 | import { bindAudioInput } from './use-audio-link.js' 4 | 5 | export default defineCustomElement('node-audio-destination', { 6 | template: html` 7 | 8 | Audio destination 9 | Input 10 | 11 | `, 12 | shadow: false, 13 | setup({ host, connected }) { 14 | const audioContext = useAudioContext() 15 | 16 | connected(() => { 17 | bindAudioInput(host.querySelector('w-graph-node-input'), audioContext.destination) 18 | }) 19 | }, 20 | }) 21 | -------------------------------------------------------------------------------- /src/shared/helpers/geometry.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @typedef {object} Point 3 | * @property {number} x 4 | * @property {number} y 5 | */ 6 | 7 | /** 8 | * @typedef {object} Box 9 | * @property {Point} min 10 | * @property {Point} max 11 | */ 12 | 13 | /** 14 | * @param {Box} a 15 | * @param {Box} b 16 | * @returns {boolean} 17 | */ 18 | export function doOverlap(a, b) { 19 | if (a.max.x < b.min.x || a.min.x > b.max.x) { 20 | return false 21 | } 22 | if (a.max.y < b.min.y || a.min.y > b.max.y) { 23 | return false 24 | } 25 | return true 26 | } 27 | 28 | /** 29 | * @param {Point} a 30 | * @param {Point} b 31 | * @returns {number} 32 | */ 33 | export function squaredDist(a, b) { 34 | const x = a.x - b.x 35 | const y = a.y - b.y 36 | return x * x + y * y 37 | } 38 | -------------------------------------------------------------------------------- /src/shared/helpers/__tests__/geometry.js: -------------------------------------------------------------------------------- 1 | import { expect, test } from '@jest/globals' 2 | import { doOverlap, squaredDist } from '../geometry' 3 | 4 | test('boxes do not overlap', () => { 5 | expect( 6 | doOverlap( 7 | { 8 | min: { x: 0, y: 0 }, 9 | max: { x: 1, y: 1 }, 10 | }, 11 | { 12 | min: { x: 2, y: 0 }, 13 | max: { x: 3, y: 1 }, 14 | }, 15 | ), 16 | ).toBe(false) 17 | }) 18 | 19 | test('boxes overlap', () => { 20 | expect( 21 | doOverlap( 22 | { 23 | min: { x: 0, y: 0 }, 24 | max: { x: 3, y: 3 }, 25 | }, 26 | { 27 | min: { x: 1, y: 1 }, 28 | max: { x: 2, y: 2 }, 29 | }, 30 | ), 31 | ).toBe(true) 32 | }) 33 | 34 | test('squaredDist', () => { 35 | expect(squaredDist({ x: 0, y: 0 }, { x: 1, y: 1 })).toBe(2) 36 | }) 37 | -------------------------------------------------------------------------------- /.github/workflows/gh-pages.yml: -------------------------------------------------------------------------------- 1 | name: GitHub Pages 2 | on: 3 | workflow_run: 4 | workflows: ['CI'] 5 | branches: [master] 6 | types: 7 | - completed 8 | jobs: 9 | deploy: 10 | name: Deploy 11 | runs-on: ubuntu-latest 12 | if: ${{ github.event.workflow_run.conclusion == 'success' }} 13 | steps: 14 | - name: Checkout 15 | uses: actions/checkout@main 16 | 17 | - name: Deploy 18 | run: | 19 | rsync -r --exclude=__tests__ src/ dist 20 | cd dist 21 | cp index.html 404.html 22 | git init 23 | git config user.email "github-actions@users.noreply.github.com" 24 | git config user.name "GitHub Actions" 25 | git add -A 26 | git commit -m 'deploy' 27 | git push -f https://x-access-token:${{ github.token }}@github.com/${{ github.repository }} master:gh-pages 28 | -------------------------------------------------------------------------------- /src/shared/base/icon.js: -------------------------------------------------------------------------------- 1 | import { css, defineCustomElement } from '../core/element.js' 2 | 3 | export default defineCustomElement('w-icon', { 4 | styles: css` 5 | :host { 6 | font-family: 'Material Icons'; 7 | font-weight: normal; 8 | font-style: normal; 9 | font-size: 24px; /* Preferred icon size */ 10 | display: inline-block; 11 | line-height: 1; 12 | text-transform: none; 13 | letter-spacing: normal; 14 | word-wrap: normal; 15 | white-space: nowrap; 16 | direction: ltr; 17 | 18 | /* Support for all WebKit browsers. */ 19 | -webkit-font-smoothing: antialiased; 20 | /* Support for Safari and Chrome. */ 21 | text-rendering: optimizeLegibility; 22 | 23 | /* Support for Firefox. */ 24 | -moz-osx-font-smoothing: grayscale; 25 | 26 | /* Support for IE. */ 27 | font-feature-settings: 'liga'; 28 | } 29 | `, 30 | }) 31 | -------------------------------------------------------------------------------- /src/shared/base/button.js: -------------------------------------------------------------------------------- 1 | import { css, defineCustomElement } from '../core/element.js' 2 | import typography from '../core/typography.js' 3 | 4 | export default defineCustomElement('w-button', { 5 | styles: css` 6 | :host { 7 | height: 36px; 8 | border-radius: 4px; 9 | padding: 0 8px; 10 | display: flex; 11 | justify-content: center; 12 | align-items: center; 13 | white-space: nowrap; 14 | cursor: pointer; 15 | user-select: none; 16 | transition: background-color 100ms var(--easing-standard); 17 | ${typography('button')} 18 | } 19 | 20 | :host(:hover) { 21 | background-color: rgba(var(--color-on-surface) / 0.04); 22 | } 23 | 24 | :host(:active) { 25 | background-color: rgba(var(--color-on-surface) / 0.16); 26 | } 27 | 28 | ::slotted(w-icon) { 29 | margin-right: 8px; 30 | font-size: 18px; 31 | pointer-events: none; 32 | } 33 | `, 34 | }) 35 | -------------------------------------------------------------------------------- /src/shared/base/menu-item.js: -------------------------------------------------------------------------------- 1 | import { css, defineCustomElement } from '../core/element.js' 2 | import typography from '../core/typography.js' 3 | 4 | export default defineCustomElement('w-menu-item', { 5 | styles: css` 6 | :host { 7 | display: flex; 8 | align-items: center; 9 | height: 32px; 10 | padding: 0 16px; 11 | cursor: pointer; 12 | user-select: none; 13 | transition: background-color 100ms var(--easing-standard); 14 | ${typography('body1')} 15 | } 16 | 17 | :host(:hover) { 18 | background-color: rgba(var(--color-on-surface) / 0.04); 19 | } 20 | 21 | :host(:active) { 22 | background-color: rgba(var(--color-on-surface) / 0.16); 23 | } 24 | 25 | :host([selected]) { 26 | background-color: rgba(var(--color-primary) / 0.08); 27 | } 28 | 29 | ::slotted(w-icon) { 30 | margin-right: 20px; 31 | } 32 | `, 33 | properties: { 34 | value: String, 35 | selected: Boolean, 36 | }, 37 | }) 38 | -------------------------------------------------------------------------------- /src/audio-node-editor/use-graph-node-menu.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @typedef {import('../shared/base/menu.js').default} Menu 3 | * @typedef {import('../shared/node-editor/graph-node.js').default} GraphNode 4 | */ 5 | 6 | /** 7 | * @param {HTMLElement} host 8 | * @param {Menu} menu 9 | */ 10 | export default function useGraphNodeMenu(host, menu) { 11 | host.addEventListener('contextmenu', (event) => { 12 | const element = /** @type {Element} */ (event.target) 13 | const graphNode = /** @type {GraphNode} */ (element.closest('w-graph-node')) 14 | if (!graphNode) { 15 | return 16 | } 17 | event.stopImmediatePropagation() 18 | event.preventDefault() 19 | 20 | if (!graphNode.selected) { 21 | if (!event.ctrlKey && !event.metaKey) { 22 | host.querySelectorAll(`w-graph-node[selected]`).forEach((/** @type {GraphNode} */ selectedGraphNode) => { 23 | selectedGraphNode.selected = false 24 | }) 25 | } 26 | graphNode.selected = true 27 | } 28 | menu.open = true 29 | menu.x = event.clientX 30 | menu.y = event.clientY 31 | }) 32 | } 33 | -------------------------------------------------------------------------------- /src/shared/base/number-field.js: -------------------------------------------------------------------------------- 1 | import { defineCustomElement, html } from '../core/element.js' 2 | 3 | /** 4 | * @typedef {import('./text-field.js').default} TextField 5 | */ 6 | 7 | export default defineCustomElement('w-number-field', { 8 | template: html``, 9 | properties: { 10 | label: String, 11 | value: Number, 12 | }, 13 | setup({ host, observe }) { 14 | const textField = /** @type {TextField} */ (host.shadowRoot.querySelector('w-text-field')) 15 | 16 | observe('label', () => { 17 | textField.label = host.label 18 | }) 19 | 20 | observe('value', () => { 21 | if (Number(textField.value) !== host.value) { 22 | textField.value = String(host.value) 23 | } 24 | }) 25 | 26 | textField.addEventListener('input', () => { 27 | host.value = Number(textField.value) 28 | }) 29 | 30 | textField.addEventListener('blur', () => { 31 | if (parseFloat(textField.value) === host.value) { 32 | textField.value = String(host.value) 33 | } 34 | }) 35 | }, 36 | }) 37 | -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Web Audio API Node Editor 6 | 7 | 8 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | -------------------------------------------------------------------------------- /src/shared/base/tab.js: -------------------------------------------------------------------------------- 1 | import { css, defineCustomElement } from '../core/element.js' 2 | import typography from '../core/typography.js' 3 | 4 | export default defineCustomElement('w-tab', { 5 | styles: css` 6 | :host { 7 | padding: 0 16px; 8 | min-width: 90px; 9 | height: 46px; 10 | border-bottom: 2px solid transparent; 11 | display: flex; 12 | justify-content: center; 13 | align-items: center; 14 | color: rgba(var(--color-on-surface) / var(--text-medium-emphasis)); 15 | text-transform: uppercase; 16 | cursor: pointer; 17 | user-select: none; 18 | transition: background-color 200ms var(--easing-standard), color 300ms linear, border-bottom-color 300ms linear; 19 | ${typography('button')} 20 | } 21 | 22 | :host([active]) { 23 | color: rgb(var(--color-primary)); 24 | border-bottom-color: rgb(var(--color-primary)); 25 | } 26 | 27 | :host(:hover) { 28 | background-color: rgba(var(--color-primary) / 0.04); 29 | } 30 | 31 | :host(:active) { 32 | background-color: rgba(var(--color-primary) / 0.16); 33 | } 34 | `, 35 | properties: { 36 | active: Boolean, 37 | }, 38 | }) 39 | -------------------------------------------------------------------------------- /src/audio-node-editor/node-white-noise.js: -------------------------------------------------------------------------------- 1 | import { defineCustomElement, html } from '../shared/core/element.js' 2 | import useAudioContext from './use-audio-context.js' 3 | import { bindAudioOutput } from './use-audio-link.js' 4 | 5 | export default defineCustomElement('node-white-noise', { 6 | template: html` 7 | 8 | White noise 9 | Output 10 | 11 | `, 12 | shadow: false, 13 | setup({ host, connected, disconnected }) { 14 | const audioContext = useAudioContext() 15 | const bufferSize = audioContext.sampleRate * 2 16 | const buffer = audioContext.createBuffer(1, bufferSize, audioContext.sampleRate) 17 | const data = buffer.getChannelData(0) 18 | for (let i = 0; i < bufferSize; i++) { 19 | data[i] = Math.random() * 2 - 1 20 | } 21 | const noise = audioContext.createBufferSource() 22 | noise.buffer = buffer 23 | noise.loop = true 24 | 25 | connected(() => { 26 | bindAudioOutput(host.querySelector('w-graph-node-output'), noise) 27 | noise.start() 28 | }) 29 | 30 | disconnected(() => { 31 | noise.stop() 32 | }) 33 | }, 34 | }) 35 | -------------------------------------------------------------------------------- /src/shared/core/theme.js: -------------------------------------------------------------------------------- 1 | import { css } from './element.js' 2 | 3 | const style = document.createElement('style') 4 | style.textContent = css` 5 | :root { 6 | --color-primary: 98 0 238; 7 | --color-secondary: 3 218 198; 8 | --color-additional1: 74 33 253; 9 | --color-additional2: 153 153 153; 10 | --color-background: 255 255 255; 11 | --color-surface: 255 255 255; 12 | --color-error: 176 0 32; 13 | 14 | --color-on-primary: 255 255 255; 15 | --color-on-secondary: 0 0 0; 16 | --color-on-background: 0 0 0; 17 | --color-on-surface: 0 0 0; 18 | 19 | /* Set to none to disable (e.g. to remove the overlay on light theme) */ 20 | --shadow: initial; 21 | --overlay: none; 22 | 23 | --text-high-emphasis: 0.87; 24 | --text-medium-emphasis: 0.6; 25 | --text-disabled: 0.38; 26 | 27 | --easing-standard: cubic-bezier(0.4, 0, 0.2, 1); 28 | --easing-decelerated: cubic-bezier(0, 0, 0.2, 1); 29 | --easing-accelerated: cubic-bezier(0.4, 0, 1, 1); 30 | } 31 | 32 | body { 33 | margin: 0; 34 | background-color: rgb(var(--color-background)); 35 | color: rgba(var(--color-on-background) / var(--text-high-emphasis)); 36 | } 37 | ` 38 | document.head.appendChild(style) 39 | -------------------------------------------------------------------------------- /src/audio-node-editor/node-constant.js: -------------------------------------------------------------------------------- 1 | import { defineCustomElement, html } from '../shared/core/element.js' 2 | import useAudioContext from './use-audio-context.js' 3 | import { bindAudioOutput } from './use-audio-link.js' 4 | import createAudioNode from './use-audio-node.js' 5 | 6 | const offsetLabel = 'Offset' 7 | 8 | export default defineCustomElement('node-constant', { 9 | template: html` 10 | 11 | Constant 12 | Output 13 | 14 | 15 | 16 | 17 | `, 18 | shadow: false, 19 | properties: { 20 | offset: Number, 21 | }, 22 | setup: createAudioNode(({ host, connected, disconnected, useAudioParam }) => { 23 | const audioContext = useAudioContext() 24 | const constant = audioContext.createConstantSource() 25 | 26 | connected(() => { 27 | bindAudioOutput(host.querySelector('w-graph-node-output'), constant) 28 | useAudioParam(host.querySelector(`w-number-field[label='${offsetLabel}']`), constant, 'offset') 29 | constant.start() 30 | }) 31 | 32 | disconnected(() => { 33 | constant.stop() 34 | }) 35 | }), 36 | }) 37 | -------------------------------------------------------------------------------- /src/audio-node-editor/node-gain.js: -------------------------------------------------------------------------------- 1 | import { defineCustomElement, html } from '../shared/core/element.js' 2 | import useAudioContext from './use-audio-context.js' 3 | import { bindAudioInput, bindAudioOutput } from './use-audio-link.js' 4 | import createAudioNode from './use-audio-node.js' 5 | 6 | const gainLabel = 'Gain' 7 | 8 | export default defineCustomElement('node-gain', { 9 | template: html` 10 | 11 | Gain 12 | Output 13 | 14 | 15 | 16 | Input 17 | 18 | `, 19 | shadow: false, 20 | properties: { 21 | gain: Number, 22 | }, 23 | setup: createAudioNode(({ host, connected, useAudioParam }) => { 24 | const audioContext = useAudioContext() 25 | const gain = audioContext.createGain() 26 | 27 | connected(() => { 28 | bindAudioOutput(host.querySelector('w-graph-node-output'), gain) 29 | useAudioParam(host.querySelector(`w-number-field[label='${gainLabel}']`), gain, 'gain') 30 | bindAudioInput(host.querySelector('w-graph-node-input:last-of-type'), gain) 31 | }) 32 | }), 33 | }) 34 | -------------------------------------------------------------------------------- /src/shared/node-editor/graph-node-socket.js: -------------------------------------------------------------------------------- 1 | import { css, defineCustomElement, html } from '../core/element.js' 2 | 3 | export default defineCustomElement('w-graph-node-socket', { 4 | styles: css` 5 | :host { 6 | width: 32px; 7 | height: 32px; 8 | border-radius: 50%; 9 | display: flex; 10 | justify-content: center; 11 | align-items: center; 12 | pointer-events: var(--socket-pointer-events, auto); 13 | transition: background-color 200ms var(--easing-standard); 14 | } 15 | 16 | :host([disabled]) { 17 | pointer-events: none; 18 | } 19 | 20 | :host(:hover:not([disabled])) { 21 | background-color: rgba(var(--color-on-surface) / 0.08); 22 | } 23 | 24 | :host(:active) { 25 | background-color: rgba(var(--color-on-surface) / 0.16); 26 | } 27 | 28 | div { 29 | width: 10px; 30 | height: 10px; 31 | border-radius: 50%; 32 | background-color: rgb(var(--color-socket, var(--color-secondary))); 33 | transition: opacity 300ms var(--easing-standard); 34 | } 35 | 36 | :host(:not(:active)) div { 37 | opacity: var(--socket-opacity); 38 | } 39 | 40 | :host([disabled]) div { 41 | opacity: var(--text-disabled); 42 | } 43 | `, 44 | template: html`
`, 45 | properties: { 46 | disabled: Boolean, 47 | }, 48 | }) 49 | -------------------------------------------------------------------------------- /src/shared/node-editor/use-mouse-navigation.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @typedef {import('./node-editor.js').default} NodeEditor 3 | */ 4 | 5 | export const defaultZoom = 1 6 | export const defaultPanX = 0 7 | export const defaultPanY = 0 8 | 9 | /** 10 | * @param {NodeEditor} host 11 | */ 12 | export default function useMouseNavigation(host) { 13 | host.zoom = defaultZoom 14 | host.panX = defaultPanX 15 | host.panY = defaultPanY 16 | 17 | host.addEventListener('wheel', (event) => { 18 | event.preventDefault() 19 | host.zoom += event.deltaY * (event.deltaMode === event.DOM_DELTA_PIXEL ? -0.001 : -0.016) 20 | host.zoom = Math.min(Math.max(0.125, host.zoom), 4) 21 | }) 22 | 23 | host.addEventListener('mousedown', (event) => { 24 | if ((event.buttons & 4) === 0 && ((event.buttons & 1) === 0 || !event.altKey)) { 25 | return 26 | } 27 | host.panning = true 28 | }) 29 | 30 | host.addEventListener('mousemove', (event) => { 31 | if (!host.panning) { 32 | return 33 | } 34 | host.panX += event.movementX / host.zoom 35 | host.panY += event.movementY / host.zoom 36 | }) 37 | 38 | host.addEventListener('click', (event) => { 39 | if (host.panning) { 40 | event.stopImmediatePropagation() 41 | host.panning = false 42 | } 43 | }) 44 | 45 | host.addEventListener('auxclick', () => { 46 | host.panning = false 47 | }) 48 | } 49 | -------------------------------------------------------------------------------- /src/shared/core/typography.js: -------------------------------------------------------------------------------- 1 | import { css } from './element.js' 2 | 3 | const light = 300 4 | const regular = 400 5 | const medium = 500 6 | 7 | export const typescale = { 8 | headline1: [light, 96, null, -1.5], 9 | headline2: [light, 60, null, -0.5], 10 | headline3: [regular, 48, null, 0], 11 | headline4: [regular, 34, null, 0.25], 12 | headline5: [regular, 24, null, 0], 13 | headline6: [medium, 20, null, 0.15], 14 | subtitle1: [regular, 16, null, 0.15], 15 | subtitle2: [medium, 14, null, 0.1], 16 | body1: [regular, 16, null, 0.5], 17 | body2: [regular, 14, null, 0.25], 18 | button: [medium, 14, 'uppercase', 1.25], 19 | caption: [regular, 12, null, 0.4], 20 | overline: [regular, 10, 'uppercase', 1.5], 21 | } 22 | 23 | /** 24 | * @param {keyof typeof typescale} name 25 | */ 26 | export default function typography(name) { 27 | const [weight, size, textTransform, letterSpacing] = typescale[name] 28 | return css` 29 | font-size: ${size}px; 30 | font-weight: ${weight}; 31 | text-transform: ${textTransform || 'none'}; 32 | letter-spacing: ${letterSpacing}px; 33 | ` 34 | } 35 | 36 | const style = document.createElement('style') 37 | style.textContent = css` 38 | @import url('https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500&display=swap'); 39 | 40 | body { 41 | font-family: 'Roboto', sans-serif; 42 | ${typography('body1')} 43 | } 44 | ` 45 | document.head.appendChild(style) 46 | -------------------------------------------------------------------------------- /src/shared/base/tooltip.js: -------------------------------------------------------------------------------- 1 | import { css, defineCustomElement, html } from '../core/element.js' 2 | import typography from '../core/typography.js' 3 | 4 | export default defineCustomElement('w-tooltip', { 5 | styles: css` 6 | :host { 7 | position: relative; 8 | display: flex; 9 | justify-content: center; 10 | } 11 | 12 | span { 13 | position: absolute; 14 | top: 100%; 15 | z-index: 30; 16 | height: 24px; 17 | margin-top: 4px; 18 | padding: 0 8px; 19 | border-radius: 4px; 20 | display: flex; 21 | align-items: center; 22 | background-color: rgb(var(--color-on-surface)); 23 | color: rgb(var(--color-surface)); 24 | pointer-events: none; 25 | opacity: 0; 26 | transform: scale(0.8); 27 | transition: opacity 150ms var(--easing-accelerated), transform 0 linear 150ms; 28 | ${typography('caption')} 29 | } 30 | 31 | :host(:hover) span { 32 | opacity: 1; 33 | transform: none; 34 | transition: opacity 150ms var(--easing-decelerated) 150ms, transform 150ms var(--easing-decelerated) 150ms; 35 | } 36 | `, 37 | template: html` 38 | 39 | 40 | `, 41 | properties: { 42 | text: String, 43 | }, 44 | setup({ host, observe }) { 45 | const span = host.shadowRoot.querySelector('span') 46 | 47 | observe('text', () => { 48 | span.textContent = host.text 49 | }) 50 | }, 51 | }) 52 | -------------------------------------------------------------------------------- /src/shared/base/fab.js: -------------------------------------------------------------------------------- 1 | import { css, defineCustomElement } from '../core/element.js' 2 | import elevation from '../core/elevation.js' 3 | import typography from '../core/typography.js' 4 | 5 | export default defineCustomElement('w-fab', { 6 | styles: css` 7 | :host { 8 | border-radius: 24px; 9 | padding: 12px 20px 12px 12px; 10 | display: flex; 11 | align-items: center; 12 | background-color: rgb(var(--color-fab, var(--color-primary))); 13 | color: rgb(var(--color-on-fab, var(--color-on-primary))); 14 | white-space: nowrap; 15 | cursor: pointer; 16 | user-select: none; 17 | transition: box-shadow 200ms var(--easing-standard); 18 | ${typography('button')} 19 | ${elevation(6)} 20 | } 21 | 22 | :host(:hover) { 23 | ${elevation(8)} 24 | } 25 | 26 | :host(:active) { 27 | ${elevation(12)} 28 | } 29 | 30 | :host::before { 31 | content: ''; 32 | position: absolute; 33 | top: 0; 34 | right: 0; 35 | bottom: 0; 36 | left: 0; 37 | border-radius: 24px; 38 | pointer-events: none; 39 | transition: background-color 200ms var(--easing-standard); 40 | } 41 | 42 | :host(:hover)::before { 43 | background-color: rgba(var(--color-on-fab, var(--color-on-primary)) / 0.08); 44 | } 45 | 46 | :host(:active)::before { 47 | background-color: rgba(var(--color-on-fab, var(--color-on-primary)) / 0.32); 48 | } 49 | 50 | ::slotted(w-icon) { 51 | margin-right: 12px; 52 | } 53 | `, 54 | }) 55 | -------------------------------------------------------------------------------- /src/core/button-play-pause.js: -------------------------------------------------------------------------------- 1 | import { css, defineCustomElement, html } from '../shared/core/element.js' 2 | 3 | const playTooltip = 'Play' 4 | const pauseTooltip = 'Pause' 5 | 6 | export default defineCustomElement('button-play-pause', { 7 | styles: css` 8 | :host { 9 | position: relative; 10 | } 11 | 12 | w-tooltip { 13 | transition: opacity 200ms var(--easing-standard); 14 | } 15 | 16 | w-tooltip[text='${pauseTooltip}'] { 17 | position: absolute; 18 | top: 0; 19 | right: 0; 20 | bottom: 0; 21 | left: 0; 22 | } 23 | 24 | w-icon { 25 | transition: transform 200ms var(--easing-standard); 26 | } 27 | 28 | :host([active]) w-tooltip[text='${playTooltip}'] { 29 | opacity: 0; 30 | } 31 | 32 | :host([active]) w-tooltip[text='${playTooltip}'] w-icon { 33 | transform: rotate(90deg); 34 | } 35 | 36 | :host(:not([active])) w-tooltip[text='${pauseTooltip}'] { 37 | opacity: 0; 38 | pointer-events: none; 39 | } 40 | 41 | :host(:not([active])) w-tooltip[text='${pauseTooltip}'] w-icon { 42 | transform: rotate(-90deg); 43 | } 44 | `, 45 | template: html` 46 | 47 | 48 | play_arrow 49 | 50 | 51 | 52 | 53 | pause 54 | 55 | 56 | `, 57 | properties: { 58 | active: Boolean, 59 | }, 60 | }) 61 | -------------------------------------------------------------------------------- /src/shared/core/__tests__/typography.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Typography 6 | 7 | 8 | 24 | 25 | 26 |

Headline 1

27 |

Headline 2

28 |

Headline 3

29 |

Headline 4

30 |
Headline 5
31 |
Headline 6
32 |
Subtitle 1
33 |
Subtitle 2
34 |

Body 1

35 |

Body 2

36 | Button 37 | Caption 38 | Overline 39 | 40 | 50 | 51 | 52 | -------------------------------------------------------------------------------- /src/audio-tracker/use-keyboard-navigation.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @param {HTMLElement} host 3 | */ 4 | export default function useKeyboardNavigation(host) { 5 | host.addEventListener('keydown', (event) => { 6 | if (!['ArrowUp', 'ArrowRight', 'ArrowDown', 'ArrowLeft'].includes(event.key)) { 7 | return 8 | } 9 | const audioTracks = [...host.querySelectorAll('audio-track')] 10 | const audioTrackIndex = audioTracks.findIndex((audioTrack) => audioTrack.matches(':focus-within')) 11 | if (audioTrackIndex === -1) { 12 | return 13 | } 14 | const audioTrack = audioTracks[audioTrackIndex] 15 | const trackEffects = [...audioTrack.querySelectorAll('track-effect')] 16 | const trackEffectIndex = trackEffects.findIndex((trackEffect) => trackEffect.matches(':focus-within')) 17 | if (trackEffectIndex === -1) { 18 | return 19 | } 20 | /** @type {Element} */ 21 | let trackEffect 22 | switch (event.key) { 23 | case 'ArrowUp': 24 | trackEffect = trackEffects[Math.max(0, trackEffectIndex - 1)] 25 | break 26 | case 'ArrowRight': 27 | const audioTrackRight = audioTracks[Math.min(audioTracks.length - 1, audioTrackIndex + 1)] 28 | trackEffect = audioTrackRight.querySelectorAll('track-effect')[trackEffectIndex] 29 | break 30 | case 'ArrowDown': 31 | trackEffect = trackEffects[Math.min(trackEffects.length - 1, trackEffectIndex + 1)] 32 | break 33 | case 'ArrowLeft': 34 | const audioTrackLeft = audioTracks[Math.max(0, audioTrackIndex - 1)] 35 | trackEffect = audioTrackLeft.querySelectorAll('track-effect')[trackEffectIndex] 36 | break 37 | } 38 | /** @type {HTMLElement} */ 39 | const nextTrackEffect = trackEffect.shadowRoot.querySelector('[tabindex]') 40 | 41 | nextTrackEffect.focus() 42 | }) 43 | } 44 | -------------------------------------------------------------------------------- /src/audio-tracker/track-effect.js: -------------------------------------------------------------------------------- 1 | import { css, defineCustomElement, html } from '../shared/core/element.js' 2 | import typography from '../shared/core/typography.js' 3 | 4 | export default defineCustomElement('track-effect', { 5 | styles: css` 6 | :host { 7 | width: 30px; 8 | display: flex; 9 | color: rgba(var(--color-on-surface) / var(--text-medium-emphasis)); 10 | font-family: 'Roboto Mono', monospace; 11 | cursor: pointer; 12 | user-select: none; 13 | ${typography('body2')} 14 | } 15 | 16 | :host([beat]) { 17 | color: rgba(var(--color-on-surface) / var(--text-high-emphasis)); 18 | } 19 | 20 | :host(:hover) { 21 | background-color: rgba(var(--color-on-surface) / 0.04); 22 | } 23 | 24 | :host(:focus-within) { 25 | background-color: rgba(var(--color-primary) / 0.12); 26 | } 27 | 28 | span { 29 | flex: 1; 30 | outline: none; 31 | text-align: center; 32 | } 33 | `, 34 | template: html`··`, 35 | properties: { 36 | value: String, 37 | beat: Boolean, 38 | }, 39 | setup({ host, observe }) { 40 | const span = host.shadowRoot.querySelector('span') 41 | 42 | observe('value', () => { 43 | if (host.value === null) { 44 | span.textContent = '··' 45 | } else { 46 | span.textContent = host.value 47 | } 48 | }) 49 | 50 | host.addEventListener('keydown', (event) => { 51 | if (event.key === 'Delete') { 52 | host.value = null 53 | return 54 | } 55 | if (!/^[0-9A-F]$/i.test(event.key)) { 56 | return 57 | } 58 | let value = host.value 59 | if (/^[0-9A-F]+$/.test(value)) { 60 | host.value = `${value.substr(1)}${event.key.toUpperCase()}` 61 | } else { 62 | host.value = `0${event.key.toUpperCase()}` 63 | } 64 | }) 65 | }, 66 | }) 67 | -------------------------------------------------------------------------------- /src/shared/base/menu.js: -------------------------------------------------------------------------------- 1 | import { css, defineCustomElement } from '../core/element.js' 2 | import elevation from '../core/elevation.js' 3 | 4 | export default defineCustomElement('w-menu', { 5 | styles: css` 6 | :host { 7 | position: fixed; 8 | min-width: 112px; 9 | padding: 8px 0; 10 | border-radius: 4px; 11 | background-color: rgb(var(--color-surface)); 12 | color: rgba(var(--color-on-surface) / var(--text-high-emphasis)); 13 | ${elevation(8)} 14 | } 15 | 16 | :host(:not([open])) { 17 | display: none; 18 | } 19 | 20 | ::slotted(hr) { 21 | margin: 8px 0; 22 | border: none; 23 | border-top: 1px solid rgba(var(--color-on-surface) / 0.12); 24 | } 25 | `, 26 | properties: { 27 | open: Boolean, 28 | x: Number, 29 | y: Number, 30 | }, 31 | setup({ host, observe }) { 32 | let width = 0 33 | let height = 0 34 | 35 | function close() { 36 | document.body.removeEventListener('mousedown', handleBodyMouseDown) 37 | host.open = false 38 | } 39 | 40 | /** 41 | * @param {MouseEvent} event 42 | */ 43 | function handleBodyMouseDown(event) { 44 | if (event.composedPath().includes(host)) { 45 | return 46 | } 47 | close() 48 | } 49 | 50 | observe('open', () => { 51 | document.body.addEventListener('mousedown', handleBodyMouseDown) 52 | if (host.open) { 53 | const boundingClientRect = host.getBoundingClientRect() 54 | width = boundingClientRect.width 55 | height = boundingClientRect.height 56 | } 57 | }) 58 | 59 | observe('x', () => { 60 | const x = Math.min(host.x, document.documentElement.clientWidth - width) 61 | host.style.left = `${x}px` 62 | }) 63 | 64 | observe('y', () => { 65 | const y = Math.min(host.y, document.documentElement.clientHeight - height) 66 | host.style.top = `${y}px` 67 | }) 68 | 69 | host.addEventListener('click', () => { 70 | close() 71 | }) 72 | }, 73 | }) 74 | -------------------------------------------------------------------------------- /src/shared/node-editor/graph-node-input.js: -------------------------------------------------------------------------------- 1 | import { css, defineCustomElement, html } from '../core/element.js' 2 | import typography from '../core/typography.js' 3 | import { nextId } from '../helpers/id.js' 4 | 5 | /** 6 | * @typedef {import('./graph-node-socket.js').default} GraphNodeSocket 7 | */ 8 | 9 | export default defineCustomElement('w-graph-node-input', { 10 | styles: css` 11 | :host { 12 | min-height: 48px; 13 | display: flex; 14 | align-items: center; 15 | color: rgba(var(--color-on-surface) / var(--text-medium-emphasis)); 16 | ${typography('body1')} 17 | } 18 | 19 | w-graph-node-socket { 20 | position: absolute; 21 | left: 0; 22 | transform: translateX(-50%); 23 | --socket-pointer-events: var(--input-socket-pointer-events); 24 | --socket-opacity: var(--input-socket-opacity); 25 | } 26 | `, 27 | template: html` 28 | 29 | 30 | `, 31 | properties: { 32 | disabled: Boolean, 33 | type: String, 34 | }, 35 | setup({ host, observe }) { 36 | const socket = /** @type {GraphNodeSocket} */ (host.shadowRoot.querySelector('w-graph-node-socket')) 37 | 38 | host.id = `input-${nextId('input')}` 39 | 40 | observe('disabled', () => { 41 | socket.disabled = host.disabled 42 | }) 43 | 44 | socket.addEventListener('mousedown', (event) => { 45 | event.stopPropagation() 46 | host.dispatchEvent( 47 | new CustomEvent('graph-link-start', { 48 | bubbles: true, 49 | detail: { to: host.id }, 50 | }), 51 | ) 52 | }) 53 | 54 | socket.addEventListener('mousemove', (event) => { 55 | event.stopPropagation() 56 | host.dispatchEvent( 57 | new CustomEvent('graph-link-end', { 58 | bubbles: true, 59 | detail: { to: host.id }, 60 | }), 61 | ) 62 | }) 63 | 64 | socket.addEventListener('mouseup', (event) => { 65 | event.stopPropagation() 66 | }) 67 | }, 68 | }) 69 | -------------------------------------------------------------------------------- /src/audio-node-editor/node-track.js: -------------------------------------------------------------------------------- 1 | import { bindAudioTrack, unbindAudioTrack } from '../audio-tracker/use-audio-tracker.js' 2 | import { defineCustomElement, html } from '../shared/core/element.js' 3 | import { bindAudioOutput } from './use-audio-link.js' 4 | import createAudioNode from './use-audio-node.js' 5 | 6 | /** 7 | * @typedef {import('./node-schedule.js').Schedule} Schedule 8 | * @typedef {import('../shared/base/select.js').default} Select 9 | * 10 | * @typedef {object} Track 11 | * @property {(timer: number) => void} trigger 12 | */ 13 | 14 | const trackLabel = 'Track' 15 | 16 | export default defineCustomElement('node-track', { 17 | template: html` 18 | 19 | Track 20 | On 21 | 22 | 23 | `, 24 | shadow: false, 25 | properties: { 26 | track: String, 27 | }, 28 | setup: createAudioNode(({ host, connected, disconnected, useProperty }) => { 29 | /** @type {Set} */ 30 | const schedules = new Set() 31 | 32 | const track = { 33 | /** 34 | * @param {number} time 35 | */ 36 | trigger(time) { 37 | schedules.forEach((schedule) => schedule.trigger(time)) 38 | }, 39 | 40 | /** 41 | * @param {Schedule} schedule 42 | */ 43 | connect(schedule) { 44 | schedules.add(schedule) 45 | }, 46 | 47 | disconnect() { 48 | schedules.clear() 49 | }, 50 | } 51 | 52 | /** @type {Select} */ 53 | let selectField 54 | 55 | connected(() => { 56 | selectField = host.querySelector(`w-select[label='${trackLabel}']`) 57 | 58 | // Must be done first to ensure the select options are populated 59 | bindAudioTrack(selectField, track) 60 | 61 | bindAudioOutput(host.querySelector('w-graph-node-output'), track) 62 | useProperty(selectField, 'track') 63 | }) 64 | 65 | disconnected(() => { 66 | unbindAudioTrack(selectField) 67 | }) 68 | }), 69 | }) 70 | -------------------------------------------------------------------------------- /src/shared/node-editor/graph-node-output.js: -------------------------------------------------------------------------------- 1 | import { css, defineCustomElement, html } from '../core/element.js' 2 | import typography from '../core/typography.js' 3 | import { nextId } from '../helpers/id.js' 4 | 5 | /** 6 | * @typedef {import('./graph-node-socket.js').default} GraphNodeSocket 7 | */ 8 | 9 | export default defineCustomElement('w-graph-node-output', { 10 | styles: css` 11 | :host { 12 | min-height: 48px; 13 | display: flex; 14 | justify-content: flex-end; 15 | align-items: center; 16 | color: rgba(var(--color-on-surface) / var(--text-medium-emphasis)); 17 | ${typography('body1')} 18 | } 19 | 20 | w-graph-node-socket { 21 | position: absolute; 22 | right: 0; 23 | transform: translateX(50%); 24 | --socket-pointer-events: var(--output-socket-pointer-events); 25 | --socket-opacity: var(--output-socket-opacity); 26 | } 27 | `, 28 | template: html` 29 | 30 | 31 | `, 32 | properties: { 33 | disabled: Boolean, 34 | type: String, 35 | }, 36 | setup({ host, observe }) { 37 | const socket = /** @type {GraphNodeSocket} */ (host.shadowRoot.querySelector('w-graph-node-socket')) 38 | 39 | host.id = `output-${nextId('output')}` 40 | 41 | observe('disabled', () => { 42 | socket.disabled = host.disabled 43 | }) 44 | 45 | socket.addEventListener('mousedown', (event) => { 46 | event.stopPropagation() 47 | host.dispatchEvent( 48 | new CustomEvent('graph-link-start', { 49 | bubbles: true, 50 | detail: { from: host.id }, 51 | }), 52 | ) 53 | }) 54 | 55 | socket.addEventListener('mousemove', (event) => { 56 | event.stopPropagation() 57 | host.dispatchEvent( 58 | new CustomEvent('graph-link-end', { 59 | bubbles: true, 60 | detail: { from: host.id }, 61 | }), 62 | ) 63 | }) 64 | 65 | socket.addEventListener('mouseup', (event) => { 66 | event.stopPropagation() 67 | }) 68 | }, 69 | }) 70 | -------------------------------------------------------------------------------- /src/audio-node-editor/node-oscillator.js: -------------------------------------------------------------------------------- 1 | import { defineCustomElement, html } from '../shared/core/element.js' 2 | import useAudioContext from './use-audio-context.js' 3 | import { bindAudioOutput } from './use-audio-link.js' 4 | import createAudioNode from './use-audio-node.js' 5 | 6 | const typeLabel = 'Type' 7 | const frequencyLabel = 'Frequency' 8 | const detuneLabel = 'Detune' 9 | 10 | export default defineCustomElement('node-oscillator', { 11 | template: html` 12 | 13 | Oscillator 14 | Output 15 | 16 | Sine 17 | Square 18 | Sawtooth 19 | Triangle 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | `, 29 | shadow: false, 30 | properties: { 31 | type: String, 32 | frequency: Number, 33 | detune: Number, 34 | }, 35 | setup: createAudioNode(({ host, connected, disconnected, useAudioProperty, useAudioParam }) => { 36 | const audioContext = useAudioContext() 37 | const oscillator = audioContext.createOscillator() 38 | 39 | connected(() => { 40 | bindAudioOutput(host.querySelector('w-graph-node-output'), oscillator) 41 | useAudioProperty(host.querySelector(`w-select[label='${typeLabel}']`), oscillator, 'type') 42 | useAudioParam(host.querySelector(`w-number-field[label='${frequencyLabel}']`), oscillator, 'frequency') 43 | useAudioParam(host.querySelector(`w-number-field[label='${detuneLabel}']`), oscillator, 'detune') 44 | oscillator.start() 45 | }) 46 | 47 | disconnected(() => { 48 | oscillator.stop() 49 | }) 50 | }), 51 | }) 52 | -------------------------------------------------------------------------------- /src/core/__tests__/waane-file.js: -------------------------------------------------------------------------------- 1 | import { expect, test } from '@jest/globals' 2 | import { defaultLines, defaultLinesPerBeat, defaultTempo } from '../../audio-tracker/use-audio-tracker' 3 | import exportFile from '../../helpers/export-file' 4 | import { clearAll } from '../../helpers/file-helper' 5 | import importFile from '../../helpers/import-file' 6 | import { defaultPanX, defaultPanY, defaultZoom } from '../../shared/node-editor/use-mouse-navigation' 7 | import fileContent01 from '../../testing/bass-drum-0.1' 8 | import fileContent02 from '../../testing/bass-drum-0.2' 9 | import fileContent03 from '../../testing/bass-drum-0.3' 10 | import fileContent from '../../testing/drum-kit' 11 | import { setup } from '../../testing/helpers' 12 | 13 | /** 14 | * @typedef {import('../../helpers/file-helper.js').FileContent} FileContent 15 | */ 16 | 17 | test('migrates from 0.1 to 0.3', () => { 18 | const { audioTracker, audioNodeEditor } = setup('Tracks') 19 | importFile(fileContent01, audioTracker, audioNodeEditor) 20 | expect(exportFile(audioTracker, audioNodeEditor)).toEqual(fileContent03) 21 | }) 22 | 23 | test('migrates from 0.2 to 0.3', () => { 24 | const { audioTracker, audioNodeEditor } = setup('Tracks') 25 | importFile(fileContent02, audioTracker, audioNodeEditor) 26 | expect(exportFile(audioTracker, audioNodeEditor)).toEqual(fileContent03) 27 | }) 28 | 29 | test('imports and exports', () => { 30 | const { audioTracker, audioNodeEditor } = setup('Tracks') 31 | 32 | /** @type {FileContent} */ 33 | const empty = { 34 | nodeEditor: { 35 | zoom: `${defaultZoom}`, 36 | 'pan-x': `${defaultPanX}`, 37 | 'pan-y': `${defaultPanY}`, 38 | }, 39 | nodes: [], 40 | links: [], 41 | tracker: { 42 | tempo: defaultTempo, 43 | lines: defaultLines, 44 | linesPerBeat: defaultLinesPerBeat, 45 | }, 46 | tracks: [], 47 | audioFiles: [], 48 | } 49 | 50 | expect(exportFile(audioTracker, audioNodeEditor)).toEqual(empty) 51 | 52 | importFile(fileContent, audioTracker, audioNodeEditor) 53 | expect(exportFile(audioTracker, audioNodeEditor)).toEqual(fileContent) 54 | 55 | clearAll(audioTracker, audioNodeEditor) 56 | expect(exportFile(audioTracker, audioNodeEditor)).toEqual(empty) 57 | }) 58 | -------------------------------------------------------------------------------- /src/shared/node-editor/use-graph-node-move.js: -------------------------------------------------------------------------------- 1 | import { isField } from '../helpers/field.js' 2 | 3 | /** 4 | * @typedef {import('./node-editor.js').default} NodeEditor 5 | * @typedef {import('./graph-node.js').default} GraphNode 6 | */ 7 | 8 | /** 9 | * @param {NodeEditor} host 10 | */ 11 | export default function useGraphNodeMove(host) { 12 | /** 13 | * @returns {NodeListOf} 14 | */ 15 | function getSelectedGraphNodes() { 16 | return host.querySelectorAll(`w-graph-node[selected]`) 17 | } 18 | 19 | /** 20 | * @returns {GraphNode} 21 | */ 22 | function getMovingGraphNode() { 23 | return host.querySelector(`w-graph-node[moving]`) 24 | } 25 | 26 | host.addEventListener('mousedown', (event) => { 27 | if (event.button !== 0 || event.altKey) { 28 | return 29 | } 30 | if (isField(/** @type {HTMLElement} */ (event.target))) { 31 | return 32 | } 33 | if (host.querySelector('w-graph-node[moving]')) { 34 | return 35 | } 36 | const element = /** @type {Element} */ (event.target) 37 | const graphNode = /** @type {GraphNode} */ (element.closest('w-graph-node')) 38 | if (graphNode) { 39 | graphNode.moving = true 40 | } 41 | }) 42 | 43 | host.addEventListener('mousemove', (event) => { 44 | const movingGraphNode = getMovingGraphNode() 45 | if (!movingGraphNode) { 46 | return 47 | } 48 | host.moving = true 49 | if (!movingGraphNode.selected) { 50 | if (!event.ctrlKey && !event.metaKey) { 51 | getSelectedGraphNodes().forEach((selectedGraphNode) => { 52 | selectedGraphNode.selected = false 53 | }) 54 | } 55 | movingGraphNode.selected = true 56 | } 57 | getSelectedGraphNodes().forEach((selectedGraphNode) => { 58 | selectedGraphNode.x += event.movementX / host.zoom 59 | selectedGraphNode.y += event.movementY / host.zoom 60 | }) 61 | }) 62 | 63 | host.addEventListener('mouseup', () => { 64 | const movingGraphNode = getMovingGraphNode() 65 | if (movingGraphNode) { 66 | movingGraphNode.moving = false 67 | } 68 | }) 69 | 70 | host.addEventListener('click', (event) => { 71 | if (host.moving) { 72 | event.stopImmediatePropagation() 73 | host.moving = false 74 | } 75 | }) 76 | } 77 | -------------------------------------------------------------------------------- /src/shared/node-editor/node-editor.js: -------------------------------------------------------------------------------- 1 | import { css, defineCustomElement, html } from '../core/element.js' 2 | import useGraphLink from './use-graph-link.js' 3 | import useGraphNodeMove from './use-graph-node-move.js' 4 | import useGraphNodeSelection from './use-graph-node-selection.js' 5 | import useMouseNavigation from './use-mouse-navigation.js' 6 | 7 | export default defineCustomElement('w-node-editor', { 8 | styles: css` 9 | :host { 10 | position: relative; 11 | display: block; 12 | overflow: hidden; 13 | } 14 | 15 | :host([panning]) { 16 | cursor: all-scroll; 17 | } 18 | 19 | :host([selecting]) { 20 | --graph-node-pointer-events: none; 21 | --socket-pointer-events: none; 22 | } 23 | 24 | :host([moving]) { 25 | cursor: move; 26 | } 27 | 28 | :host([linking]) { 29 | --graph-node-pointer-events: none; 30 | } 31 | 32 | :host([linking='output']) { 33 | --output-socket-pointer-events: none; 34 | --output-socket-opacity: var(--text-disabled); 35 | } 36 | 37 | :host([linking='input']) { 38 | --input-socket-pointer-events: none; 39 | --input-socket-opacity: var(--text-disabled); 40 | } 41 | `, 42 | template: html` 43 | 44 | 45 | 46 | `, 47 | properties: { 48 | zoom: Number, 49 | panX: Number, 50 | panY: Number, 51 | panning: Boolean, 52 | selecting: Boolean, 53 | moving: Boolean, 54 | linking: String, // 'output' or 'input' 55 | }, 56 | setup({ host, connected, disconnected }) { 57 | /** @type {HTMLElement} */ 58 | const graph = host.shadowRoot.querySelector('w-graph') 59 | 60 | // The order does matter because each one can stop the immediate 61 | // propagation to the next one 62 | useGraphLink(host) 63 | useMouseNavigation(host) 64 | useGraphNodeMove(host) 65 | useGraphNodeSelection(host) 66 | 67 | const observer = new MutationObserver(() => { 68 | graph.style.transform = `scale(${host.zoom}) translate(${host.panX}px, ${host.panY}px)` 69 | }) 70 | 71 | connected(() => { 72 | observer.observe(host, { attributeFilter: ['zoom', 'pan-x', 'pan-y'] }) 73 | }) 74 | 75 | disconnected(() => { 76 | observer.disconnect() 77 | }) 78 | }, 79 | }) 80 | -------------------------------------------------------------------------------- /src/shared/core/__tests__/elevation.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Elevation 6 | 7 | 8 | 44 | 45 | 46 |
47 |
48 | 49 | 67 | 68 | 69 | -------------------------------------------------------------------------------- /src/audio-node-editor/node-schedule.js: -------------------------------------------------------------------------------- 1 | import { defineCustomElement, html } from '../shared/core/element.js' 2 | import { bindAudioInput, bindAudioOutput } from './use-audio-link.js' 3 | import createAudioNode from './use-audio-node.js' 4 | 5 | /** 6 | * @typedef {import('../shared/base/number-field.js').default} NumberField 7 | * @typedef {import('./use-audio-link.js').Source} Source 8 | * 9 | * @typedef {object} ScheduleProperties 10 | * @property {(timer: number) => void} trigger 11 | * 12 | * @typedef {Source & ScheduleProperties} Schedule 13 | */ 14 | 15 | const targetValueLabel = 'Target value' 16 | const startTimeLabel = 'Start time' 17 | const timeConstantLabel = 'Time constant' 18 | 19 | export default defineCustomElement('node-schedule', { 20 | template: html` 21 | 22 | Schedule 23 | Envelope 24 | 25 | 26 | 27 | Trigger 28 | 29 | `, 30 | shadow: false, 31 | properties: { 32 | targetValue: Number, 33 | startTime: Number, 34 | timeConstant: Number, 35 | }, 36 | setup: createAudioNode(({ host, connected, useProperty }) => { 37 | /** @type {Set} */ 38 | const audioParams = new Set() 39 | 40 | /** @type {Schedule} */ 41 | const schedule = { 42 | trigger(time) { 43 | audioParams.forEach((audioParam) => { 44 | audioParam.setTargetAtTime(host.targetValue, time + host.startTime, host.timeConstant) 45 | }) 46 | }, 47 | 48 | /** 49 | * @param {AudioParam} audioParam 50 | */ 51 | connect(audioParam) { 52 | audioParams.add(audioParam) 53 | }, 54 | 55 | disconnect() { 56 | audioParams.clear() 57 | }, 58 | } 59 | 60 | connected(() => { 61 | bindAudioOutput(host.querySelector('w-graph-node-output'), schedule) 62 | useProperty(host.querySelector(`w-number-field[label='${targetValueLabel}']`), 'targetValue') 63 | useProperty(host.querySelector(`w-number-field[label='${startTimeLabel}']`), 'startTime') 64 | useProperty(host.querySelector(`w-number-field[label='${timeConstantLabel}']`), 'timeConstant') 65 | bindAudioInput(host.querySelector('w-graph-node-input'), schedule) 66 | }) 67 | }), 68 | }) 69 | -------------------------------------------------------------------------------- /src/helpers/file-helper.js: -------------------------------------------------------------------------------- 1 | import { defaultLines, defaultLinesPerBeat, defaultTempo } from '../audio-tracker/use-audio-tracker.js' 2 | import { clearAllIds } from '../shared/helpers/id.js' 3 | import { defaultPanX, defaultPanY, defaultZoom } from '../shared/node-editor/use-mouse-navigation.js' 4 | 5 | /** 6 | * @typedef {import('../audio-tracker/audio-tracker.js').default} AudioTracker 7 | * @typedef {import('../shared/node-editor/node-editor.js').default} NodeEditor 8 | * 9 | * @typedef {object} FileContentNode 10 | * @property {string} name 11 | * @property {number} x 12 | * @property {number} y 13 | * @property {Object} attributes 14 | * @property {string[]} outputs 15 | * @property {string[]} inputs 16 | * 17 | * @typedef {object} FileContentLink 18 | * @property {string} from 19 | * @property {string} to 20 | * 21 | * @typedef {object} FileContentTracker 22 | * @property {number} tempo 23 | * @property {number} lines 24 | * @property {number} linesPerBeat 25 | * 26 | * @typedef {object} FileContentTrack 27 | * @property {string} label 28 | * @property {Object} effects 29 | * 30 | * @typedef {object} FileContentAudioFile 31 | * @property {string} hash 32 | * @property {number} length 33 | * @property {number} sampleRate 34 | * @property {string[]} channels 35 | * 36 | * @typedef {object} FileContent 37 | * @property {Object} nodeEditor 38 | * @property {FileContentNode[]} nodes 39 | * @property {FileContentLink[]} links 40 | * @property {FileContentTracker} tracker 41 | * @property {FileContentTrack[]} tracks 42 | * @property {FileContentAudioFile[]} audioFiles 43 | */ 44 | 45 | /** 46 | * @param {AudioTracker} audioTracker 47 | * @param {HTMLElement} audioNodeEditor 48 | */ 49 | export function clearAll(audioTracker, audioNodeEditor) { 50 | audioNodeEditor.shadowRoot.querySelectorAll('w-graph-node').forEach((graphNode) => { 51 | graphNode.parentElement.remove() 52 | }) 53 | audioNodeEditor.shadowRoot.querySelectorAll('w-graph-link').forEach((graphLink) => { 54 | graphLink.remove() 55 | }) 56 | audioTracker.shadowRoot.querySelectorAll('audio-track').forEach((audioTrack) => { 57 | audioTrack.remove() 58 | }) 59 | clearAllIds() 60 | 61 | /** @type {NodeEditor} */ 62 | const nodeEditor = audioNodeEditor.shadowRoot.querySelector('w-node-editor') 63 | 64 | nodeEditor.zoom = defaultZoom 65 | nodeEditor.panX = defaultPanX 66 | nodeEditor.panY = defaultPanY 67 | audioTracker.tempo = defaultTempo 68 | audioTracker.lines = defaultLines 69 | audioTracker.linesPerBeat = defaultLinesPerBeat 70 | } 71 | -------------------------------------------------------------------------------- /src/shared/base/file.js: -------------------------------------------------------------------------------- 1 | import { css, defineCustomElement, html } from '../core/element.js' 2 | import typography from '../core/typography.js' 3 | 4 | /** 5 | * @typedef {import('./tooltip.js').default} Tooltip 6 | * 7 | * @typedef {object} FileLoadEventDetail 8 | * @property {string} name 9 | * @property {ArrayBuffer} content 10 | * 11 | * @typedef {CustomEvent} FileLoadEvent 12 | */ 13 | 14 | export default defineCustomElement('w-file', { 15 | styles: css` 16 | :host { 17 | width: 208px; 18 | display: flex; 19 | flex-direction: column; 20 | } 21 | 22 | w-button { 23 | margin-bottom: 8px; 24 | transition: color 200ms var(--easing-standard); 25 | } 26 | 27 | :host([name]) w-button { 28 | color: rgba(var(--color-on-surface) / var(--text-medium-emphasis)); 29 | } 30 | 31 | span { 32 | margin-bottom: 8px; 33 | overflow: hidden; 34 | text-overflow: ellipsis; 35 | white-space: nowrap; 36 | color: rgba(var(--color-on-surface) / var(--text-medium-emphasis)); 37 | transition: color 200ms var(--easing-standard); 38 | ${typography('body2')} 39 | } 40 | 41 | :host([name]) span { 42 | color: rgba(var(--color-on-surface) / var(--text-high-emphasis)); 43 | } 44 | `, 45 | template: html` 46 | cloud_uploadChoose a file 47 | 48 | No file selected 49 | 50 | `, 51 | properties: { 52 | name: String, 53 | }, 54 | setup({ host, observe }) { 55 | /** @type {Tooltip} */ 56 | const tooltip = host.shadowRoot.querySelector('w-tooltip') 57 | 58 | const fileName = host.shadowRoot.querySelector('span') 59 | 60 | const input = document.createElement('input') 61 | input.type = 'file' 62 | 63 | observe('name', () => { 64 | tooltip.text = host.name 65 | fileName.textContent = host.name 66 | }) 67 | 68 | host.addEventListener('click', () => { 69 | input.click() 70 | }) 71 | 72 | input.addEventListener('change', async () => { 73 | if (input.files.length !== 1) { 74 | return 75 | } 76 | const file = input.files[0] 77 | host.name = file.name 78 | const content = await file.arrayBuffer() 79 | 80 | /** @type {FileLoadEvent} */ 81 | const fileLoadEvent = new CustomEvent('file-load', { 82 | detail: { 83 | name: file.name, 84 | content, 85 | }, 86 | }) 87 | 88 | host.dispatchEvent(fileLoadEvent) 89 | }) 90 | }, 91 | }) 92 | -------------------------------------------------------------------------------- /src/audio-node-editor/use-audio-link.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @typedef {import('../shared/node-editor/use-graph-link.js').GraphLinkEvent} GraphLinkEvent 3 | * @typedef {import('./node-schedule.js').Schedule} Schedule 4 | * 5 | * @typedef {AudioNode | AudioParam | Schedule} Destination 6 | * 7 | * @typedef {object} Source 8 | * @property {(destination: Destination) => void} connect 9 | * @property {() => void} disconnect 10 | */ 11 | 12 | /** @type {WeakMap} */ 13 | const audioOutputs = new WeakMap() 14 | 15 | /** @type {WeakMap} */ 16 | const audioInputs = new WeakMap() 17 | 18 | /** @type {WeakMap>} */ 19 | const links = new WeakMap() 20 | 21 | /** 22 | * @param {HTMLElement} element 23 | * @param {Source} source 24 | */ 25 | export function bindAudioOutput(element, source) { 26 | audioOutputs.set(element, source) 27 | } 28 | 29 | /** 30 | * @param {HTMLElement} element 31 | * @param {Destination} destination 32 | */ 33 | export function bindAudioInput(element, destination) { 34 | audioInputs.set(element, destination) 35 | } 36 | 37 | /** 38 | * @param {HTMLElement} host 39 | */ 40 | export default function useAudioLink(host) { 41 | host.addEventListener('graph-link-connect', (/** @type {GraphLinkEvent} */ event) => { 42 | /** @type {HTMLElement} */ 43 | const output = host.querySelector(`w-graph-node-output#${event.detail.from}`) 44 | 45 | /** @type {HTMLElement} */ 46 | const input = host.querySelector(`w-graph-node-input#${event.detail.to}`) 47 | 48 | const source = audioOutputs.get(output) 49 | const destination = audioInputs.get(input) 50 | source.connect(destination) 51 | 52 | if (links.has(output)) { 53 | links.get(output).add(input) 54 | } else { 55 | links.set(output, new Set([input])) 56 | } 57 | }) 58 | 59 | host.addEventListener('graph-link-disconnect', (/** @type {GraphLinkEvent} */ event) => { 60 | /** @type {HTMLElement} */ 61 | const output = host.querySelector(`w-graph-node-output#${event.detail.from}`) 62 | 63 | /** @type {HTMLElement} */ 64 | const disconnectedInput = host.querySelector(`w-graph-node-input#${event.detail.to}`) 65 | 66 | const source = audioOutputs.get(output) 67 | source.disconnect() 68 | 69 | const inputs = links.get(output) 70 | inputs.delete(disconnectedInput) 71 | 72 | if (inputs.size > 0) { 73 | inputs.forEach((input) => { 74 | const destination = audioInputs.get(input) 75 | source.connect(destination) 76 | }) 77 | } else { 78 | links.delete(output) 79 | } 80 | }) 81 | } 82 | -------------------------------------------------------------------------------- /src/shared/core/__tests__/__snapshots__/typography.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`returns styles for body1 1`] = ` 4 | " 5 | font-size: 16px; 6 | font-weight: 400; 7 | text-transform: none; 8 | letter-spacing: 0.5px; 9 | " 10 | `; 11 | 12 | exports[`returns styles for body2 1`] = ` 13 | " 14 | font-size: 14px; 15 | font-weight: 400; 16 | text-transform: none; 17 | letter-spacing: 0.25px; 18 | " 19 | `; 20 | 21 | exports[`returns styles for button 1`] = ` 22 | " 23 | font-size: 14px; 24 | font-weight: 500; 25 | text-transform: uppercase; 26 | letter-spacing: 1.25px; 27 | " 28 | `; 29 | 30 | exports[`returns styles for caption 1`] = ` 31 | " 32 | font-size: 12px; 33 | font-weight: 400; 34 | text-transform: none; 35 | letter-spacing: 0.4px; 36 | " 37 | `; 38 | 39 | exports[`returns styles for headline1 1`] = ` 40 | " 41 | font-size: 96px; 42 | font-weight: 300; 43 | text-transform: none; 44 | letter-spacing: -1.5px; 45 | " 46 | `; 47 | 48 | exports[`returns styles for headline2 1`] = ` 49 | " 50 | font-size: 60px; 51 | font-weight: 300; 52 | text-transform: none; 53 | letter-spacing: -0.5px; 54 | " 55 | `; 56 | 57 | exports[`returns styles for headline3 1`] = ` 58 | " 59 | font-size: 48px; 60 | font-weight: 400; 61 | text-transform: none; 62 | letter-spacing: 0px; 63 | " 64 | `; 65 | 66 | exports[`returns styles for headline4 1`] = ` 67 | " 68 | font-size: 34px; 69 | font-weight: 400; 70 | text-transform: none; 71 | letter-spacing: 0.25px; 72 | " 73 | `; 74 | 75 | exports[`returns styles for headline5 1`] = ` 76 | " 77 | font-size: 24px; 78 | font-weight: 400; 79 | text-transform: none; 80 | letter-spacing: 0px; 81 | " 82 | `; 83 | 84 | exports[`returns styles for headline6 1`] = ` 85 | " 86 | font-size: 20px; 87 | font-weight: 500; 88 | text-transform: none; 89 | letter-spacing: 0.15px; 90 | " 91 | `; 92 | 93 | exports[`returns styles for overline 1`] = ` 94 | " 95 | font-size: 10px; 96 | font-weight: 400; 97 | text-transform: uppercase; 98 | letter-spacing: 1.5px; 99 | " 100 | `; 101 | 102 | exports[`returns styles for subtitle1 1`] = ` 103 | " 104 | font-size: 16px; 105 | font-weight: 400; 106 | text-transform: none; 107 | letter-spacing: 0.15px; 108 | " 109 | `; 110 | 111 | exports[`returns styles for subtitle2 1`] = ` 112 | " 113 | font-size: 14px; 114 | font-weight: 500; 115 | text-transform: none; 116 | letter-spacing: 0.1px; 117 | " 118 | `; 119 | -------------------------------------------------------------------------------- /src/shared/base/select.js: -------------------------------------------------------------------------------- 1 | import { css, defineCustomElement, html } from '../core/element.js' 2 | 3 | /** 4 | * @typedef {import('./menu.js').default} Menu 5 | * @typedef {import('./menu-item.js').default} MenuItem 6 | * @typedef {import('./text-field.js').default} TextField 7 | */ 8 | 9 | export default defineCustomElement('w-select', { 10 | styles: css` 11 | :host { 12 | position: relative; 13 | } 14 | 15 | w-menu { 16 | position: absolute; 17 | top: 100%; 18 | width: 100%; 19 | margin-top: -16px; 20 | } 21 | 22 | w-text-field { 23 | cursor: pointer; 24 | } 25 | 26 | w-icon { 27 | transition: transform 250ms var(--easing-standard); 28 | } 29 | 30 | w-menu[open] + w-text-field w-icon { 31 | transform: rotate(180deg); 32 | } 33 | `, 34 | template: html` 35 | 36 | 37 | 38 | arrow_drop_down 39 | 40 | `, 41 | properties: { 42 | label: String, 43 | value: String, 44 | }, 45 | setup({ host, observe }) { 46 | const menu = /** @type {Menu} */ (host.shadowRoot.querySelector('w-menu')) 47 | const menuSlot = menu.querySelector('slot') 48 | const textField = /** @type {TextField} */ (host.shadowRoot.querySelector('w-text-field')) 49 | const span = host.shadowRoot.querySelector('span') 50 | 51 | /** @type {boolean} */ 52 | let isMenuOpenOnMouseDown 53 | 54 | observe('label', () => { 55 | textField.label = host.label 56 | }) 57 | 58 | observe('value', () => { 59 | textField.value = host.value 60 | span.textContent = null 61 | menuSlot.assignedElements().forEach((element) => { 62 | if (!element.matches('w-menu-item')) { 63 | return 64 | } 65 | const menuItem = /** @type {MenuItem} */ (element) 66 | if (menuItem.value === host.value) { 67 | span.textContent = menuItem.textContent 68 | menuItem.selected = true 69 | } else { 70 | menuItem.selected = false 71 | } 72 | }) 73 | }) 74 | 75 | host.addEventListener('mousedown', () => { 76 | isMenuOpenOnMouseDown = menu.open 77 | }) 78 | 79 | host.addEventListener('click', () => { 80 | menu.open = !isMenuOpenOnMouseDown 81 | }) 82 | 83 | menu.addEventListener('mousedown', (event) => { 84 | event.stopPropagation() 85 | }) 86 | 87 | menu.addEventListener('click', (event) => { 88 | // Prevents a menu item click to reopen the menu 89 | event.stopPropagation() 90 | 91 | const menuItem = /** @type {MenuItem} */ (event.target) 92 | if (menuItem.value != null) { 93 | host.value = menuItem.value 94 | host.dispatchEvent(new Event('input')) 95 | } 96 | }) 97 | }, 98 | }) 99 | -------------------------------------------------------------------------------- /src/shared/node-editor/graph-node.js: -------------------------------------------------------------------------------- 1 | import { css, defineCustomElement, html } from '../core/element.js' 2 | import elevation from '../core/elevation.js' 3 | import typography from '../core/typography.js' 4 | 5 | export default defineCustomElement('w-graph-node', { 6 | styles: css` 7 | :host { 8 | position: absolute; 9 | display: flex; 10 | flex-direction: column; 11 | padding: 16px; 12 | border-radius: 4px; 13 | background-color: rgb(var(--color-surface)); 14 | color: rgba(var(--color-on-surface) / var(--text-high-emphasis)); 15 | transition: color 150ms var(--easing-standard), box-shadow 150ms var(--easing-standard); 16 | pointer-events: var(--graph-node-pointer-events); 17 | user-select: none; 18 | ${elevation(1)} 19 | } 20 | 21 | :host([selected]) { 22 | ${elevation(2)} 23 | } 24 | 25 | :host(:hover), 26 | :host([moving]) { 27 | ${elevation(3)} 28 | } 29 | 30 | :host([selected][selecting]) { 31 | color: rgba(var(--color-on-surface) / var(--text-medium-emphasis)); 32 | } 33 | 34 | w-icon { 35 | position: absolute; 36 | top: 0; 37 | right: 0; 38 | bottom: 0; 39 | left: 0; 40 | padding: 8px; 41 | border-radius: 4px; 42 | text-align: right; 43 | color: transparent; 44 | pointer-events: none; 45 | transition: background-color 200ms var(--easing-standard), color 100ms var(--easing-accelerated); 46 | } 47 | 48 | :host(:hover) w-icon { 49 | background-color: rgba(var(--color-on-surface) / 0.04); 50 | } 51 | 52 | :host([selected]) w-icon { 53 | background-color: rgba(var(--color-primary) / 0.08); 54 | color: rgb(var(--color-primary)); 55 | transition: background-color 200ms var(--easing-standard), color 100ms var(--easing-decelerated); 56 | } 57 | 58 | :host(:active) w-icon, 59 | :host([selecting]) w-icon { 60 | background-color: rgba(var(--color-primary) / 0.16); 61 | } 62 | 63 | :host([selected][selecting]) w-icon { 64 | background-color: transparent; 65 | color: rgba(var(--color-primary) / 0.24); 66 | } 67 | 68 | slot[name='title'] { 69 | display: flex; 70 | align-items: center; 71 | height: 32px; 72 | margin-right: 32px; 73 | white-space: nowrap; 74 | ${typography('headline6')} 75 | } 76 | `, 77 | template: html` 78 | check_circle 79 | 80 | 81 | `, 82 | properties: { 83 | x: Number, 84 | y: Number, 85 | selecting: Boolean, 86 | selected: Boolean, 87 | moving: Boolean, 88 | }, 89 | setup({ host, observe }) { 90 | observe('x', () => { 91 | host.style.left = `${host.x}px` 92 | }) 93 | 94 | observe('y', () => { 95 | host.style.top = `${host.y}px` 96 | }) 97 | }, 98 | }) 99 | -------------------------------------------------------------------------------- /src/audio-node-editor/node-biquad-filter.js: -------------------------------------------------------------------------------- 1 | import { defineCustomElement, html } from '../shared/core/element.js' 2 | import useAudioContext from './use-audio-context.js' 3 | import { bindAudioInput, bindAudioOutput } from './use-audio-link.js' 4 | import createAudioNode from './use-audio-node.js' 5 | 6 | const typeLabel = 'Type' 7 | const frequencyLabel = 'Frequency' 8 | const detuneLabel = 'Detune' 9 | const QLabel = 'Q' 10 | const gainLabel = 'Gain' 11 | 12 | export default defineCustomElement('node-biquad-filter', { 13 | template: html` 14 | 15 | Biquad filter 16 | Output 17 | 18 | Lowpass 19 | Highpass 20 | Bandpass 21 | Lowshelf 22 | Highshelf 23 | Peaking 24 | Notch 25 | Allpass 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | Input 40 | 41 | `, 42 | shadow: false, 43 | properties: { 44 | type: String, 45 | frequency: Number, 46 | detune: Number, 47 | Q: Number, 48 | gain: Number, 49 | }, 50 | setup: createAudioNode(({ host, connected, useAudioProperty, useAudioParam }) => { 51 | const audioContext = useAudioContext() 52 | const biquadFilter = audioContext.createBiquadFilter() 53 | 54 | connected(() => { 55 | bindAudioOutput(host.querySelector('w-graph-node-output'), biquadFilter) 56 | useAudioProperty(host.querySelector(`w-select[label='${typeLabel}']`), biquadFilter, 'type') 57 | useAudioParam(host.querySelector(`w-number-field[label='${frequencyLabel}']`), biquadFilter, 'frequency') 58 | useAudioParam(host.querySelector(`w-number-field[label='${detuneLabel}']`), biquadFilter, 'detune') 59 | useAudioParam(host.querySelector(`w-number-field[label='${QLabel}']`), biquadFilter, 'Q') 60 | useAudioParam(host.querySelector(`w-number-field[label='${gainLabel}']`), biquadFilter, 'gain') 61 | bindAudioInput(host.querySelector('w-graph-node-input:last-of-type'), biquadFilter) 62 | }) 63 | }), 64 | }) 65 | -------------------------------------------------------------------------------- /src/audio-node-editor/__tests__/audio-link.js: -------------------------------------------------------------------------------- 1 | import { expect, test } from '@jest/globals' 2 | import { click, contextMenu, setup } from '../../testing/helpers' 3 | 4 | test('connects and disconnects audio nodes', () => { 5 | const { oscillatorMock, nodeEditor, addAudioNode, getGraphNodes, addGraphLink } = setup('Nodes') 6 | addAudioNode('Oscillator') 7 | addAudioNode('Audio destination') 8 | const [oscillator, audioDestination] = getGraphNodes() 9 | addGraphLink(oscillator, audioDestination) 10 | 11 | expect(oscillatorMock.connect).toHaveBeenCalledTimes(1) 12 | 13 | const graphNodeInput = audioDestination.querySelector('w-graph-node-input') 14 | const inputSocket = graphNodeInput.shadowRoot.querySelector('w-graph-node-socket') 15 | inputSocket.dispatchEvent(new MouseEvent('mousedown')) 16 | nodeEditor.dispatchEvent(new MouseEvent('mousemove')) 17 | 18 | expect(oscillatorMock.disconnect).toHaveBeenCalledTimes(1) 19 | }) 20 | 21 | test('disconnects audio nodes when deleting output node', () => { 22 | const { oscillatorMock, getMenuItem, addAudioNode, getGraphNodes, addGraphLink } = setup('Nodes') 23 | addAudioNode('Oscillator') 24 | addAudioNode('Audio destination') 25 | const [oscillator, audioDestination] = getGraphNodes() 26 | addGraphLink(oscillator, audioDestination) 27 | 28 | contextMenu(oscillator) 29 | getMenuItem('Delete').click() 30 | 31 | expect(oscillatorMock.disconnect).toHaveBeenCalledTimes(1) 32 | }) 33 | 34 | test('disconnects audio nodes when deleting input node', () => { 35 | const { oscillatorMock, getMenuItem, addAudioNode, getGraphNodes, addGraphLink } = setup('Nodes') 36 | addAudioNode('Oscillator') 37 | addAudioNode('Audio destination') 38 | const [oscillator, audioDestination] = getGraphNodes() 39 | addGraphLink(oscillator, audioDestination) 40 | 41 | contextMenu(audioDestination) 42 | getMenuItem('Delete').click() 43 | 44 | expect(oscillatorMock.disconnect).toHaveBeenCalledTimes(1) 45 | }) 46 | 47 | test('connects linked audio nodes when duplicating them', () => { 48 | const { oscillatorMock, getMenuItem, addAudioNode, getGraphNodes, addGraphLink } = setup('Nodes') 49 | addAudioNode('Oscillator') 50 | addAudioNode('Audio destination') 51 | addAudioNode('Audio destination') 52 | addAudioNode('Oscillator') 53 | const [oscillator1, audioDestination1, audioDestination2, oscillator2] = getGraphNodes() 54 | 55 | addGraphLink(oscillator1, audioDestination1) 56 | addGraphLink(oscillator2, audioDestination2) 57 | 58 | expect(oscillatorMock.connect).toHaveBeenCalledTimes(2) 59 | oscillatorMock.connect.mockClear() 60 | 61 | click(oscillator1) 62 | click(audioDestination1, { ctrlKey: true }) 63 | contextMenu(oscillator1) 64 | getMenuItem('Duplicate').click() 65 | 66 | expect(oscillatorMock.connect).toHaveBeenCalledTimes(1) 67 | oscillatorMock.connect.mockClear() 68 | 69 | click(oscillator2) 70 | click(audioDestination2, { ctrlKey: true }) 71 | contextMenu(oscillator2) 72 | getMenuItem('Duplicate').click() 73 | 74 | expect(oscillatorMock.connect).toHaveBeenCalledTimes(1) 75 | }) 76 | -------------------------------------------------------------------------------- /src/audio-node-editor/__tests__/node-track.js: -------------------------------------------------------------------------------- 1 | import { expect, test } from '@jest/globals' 2 | import { contextMenu, findFieldByLabel, getSelectOption, getSelectOptions, setup } from '../../testing/helpers' 3 | 4 | /** 5 | * @typedef {import('../../shared/base/select.js').default} Select 6 | */ 7 | 8 | test('is initialized with existing tracks', () => { 9 | const { navigateTo, getMenuItem, addAudioNode, getGraphNodes, addAudioTrack, getAudioTracks } = setup('Tracks') 10 | addAudioTrack() 11 | addAudioTrack() 12 | const audioTracks = getAudioTracks() 13 | const [audioTrack1] = audioTracks 14 | contextMenu(audioTrack1) 15 | getMenuItem('Delete track').click() 16 | 17 | navigateTo('Nodes') 18 | addAudioNode('Track') 19 | const [nodeTrack] = getGraphNodes() 20 | const trackField = findFieldByLabel(nodeTrack, 'w-select', 'Track') 21 | trackField.click() 22 | 23 | expect(getSelectOptions(trackField).map((option) => option.textContent)).toEqual(['2']) 24 | }) 25 | 26 | test('updates when adding tracks', () => { 27 | const { navigateTo, addAudioNode, getGraphNodes, addAudioTrack } = setup('Nodes') 28 | addAudioNode('Track') 29 | const [nodeTrack] = getGraphNodes() 30 | const trackField = /** @type {Select} */ (findFieldByLabel(nodeTrack, 'w-select', 'Track')) 31 | 32 | trackField.click() 33 | expect(getSelectOptions(trackField)).toHaveLength(0) 34 | expect(trackField.value).toBeNull() 35 | 36 | navigateTo('Tracks') 37 | addAudioTrack() 38 | navigateTo('Nodes') 39 | trackField.click() 40 | expect(getSelectOptions(trackField).map((option) => option.textContent)).toEqual(['1']) 41 | expect(trackField.value).toBeNull() 42 | 43 | trackField.click() 44 | getSelectOption(trackField, '1').click() 45 | expect(trackField.value).toBe('1') 46 | }) 47 | 48 | test('updates when deleting tracks', () => { 49 | const { navigateTo, getMenuItem, addAudioNode, getGraphNodes, addAudioTrack, getAudioTracks } = setup('Tracks') 50 | addAudioTrack() 51 | addAudioTrack() 52 | addAudioTrack() 53 | const [audioTrack1, audioTrack2] = getAudioTracks() 54 | navigateTo('Nodes') 55 | addAudioNode('Track') 56 | const [nodeTrack] = getGraphNodes() 57 | const trackField = /** @type {Select} */ (findFieldByLabel(nodeTrack, 'w-select', 'Track')) 58 | trackField.click() 59 | expect(getSelectOptions(trackField).map((option) => option.textContent)).toEqual(['1', '2', '3']) 60 | expect(trackField.value).toBeNull() 61 | 62 | getSelectOption(trackField, '2').click() 63 | expect(trackField.value).toBe('2') 64 | 65 | navigateTo('Tracks') 66 | contextMenu(audioTrack1) 67 | getMenuItem('Delete track').click() 68 | navigateTo('Nodes') 69 | trackField.click() 70 | expect(getSelectOptions(trackField).map((option) => option.textContent)).toEqual(['2', '3']) 71 | expect(trackField.value).toBe('2') 72 | 73 | navigateTo('Tracks') 74 | contextMenu(audioTrack2) 75 | getMenuItem('Delete track').click() 76 | navigateTo('Nodes') 77 | trackField.click() 78 | expect(getSelectOptions(trackField).map((option) => option.textContent)).toEqual(['3']) 79 | expect(trackField.value).toBeNull() 80 | }) 81 | -------------------------------------------------------------------------------- /src/audio-node-editor/use-audio-link-type.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @typedef {import('../shared/node-editor/graph-node-output.js').default} GraphNodeOutput 3 | * @typedef {import('../shared/node-editor/graph-node-input.js').default} GraphNodeInput 4 | * @typedef {import('../shared/node-editor/graph-link.js').default} GraphLink 5 | * @typedef {import('../shared/node-editor/use-graph-link.js').GraphLinkEvent} GraphLinkEvent 6 | */ 7 | 8 | /** 9 | * @param {HTMLElement} host 10 | */ 11 | export default function useAudioLinkType(host) { 12 | /** @type {'output' | 'input'} */ 13 | let linking = null 14 | 15 | /** 16 | * @param {GraphLinkEvent} event 17 | */ 18 | function getLinkingType(event) { 19 | if (event.detail.from) { 20 | const output = /** @type {GraphNodeOutput} */ (event.target) 21 | linking = 'output' 22 | return output.type 23 | } else { 24 | const graphLink = /** @type {GraphLink} */ (host.querySelector(`w-graph-link[to='${event.detail.to}']`)) 25 | const output = /** @type {GraphNodeOutput} */ (graphLink?.from && 26 | host.querySelector(`w-graph-node-output#${graphLink.from}`)) 27 | if (output) { 28 | linking = 'output' 29 | return output.type 30 | } else { 31 | const input = /** @type {GraphNodeInput} */ (event.target) 32 | linking = 'input' 33 | return input.type 34 | } 35 | } 36 | } 37 | 38 | /** 39 | * @param {string} selectors 40 | */ 41 | function disableSockets(selectors) { 42 | host.querySelectorAll(selectors).forEach((/** @type {GraphNodeOutput | GraphNodeInput} */ outputOrInput) => { 43 | outputOrInput.disabled = true 44 | }) 45 | } 46 | 47 | host.addEventListener('graph-link-start', (/** @type {GraphLinkEvent} */ event) => { 48 | const linkingType = getLinkingType(event) 49 | 50 | switch (linking) { 51 | case 'output': 52 | switch (linkingType) { 53 | case 'trigger': 54 | disableSockets(`w-graph-node-input:not([type='trigger'])`) 55 | break 56 | case 'audio': 57 | disableSockets(`w-graph-node-input[type='trigger']`) 58 | break 59 | default: 60 | disableSockets(`w-graph-node-input[type]`) 61 | } 62 | break 63 | case 'input': 64 | switch (linkingType) { 65 | case 'trigger': 66 | disableSockets(`w-graph-node-output:not([type='trigger'])`) 67 | break 68 | case 'audio': 69 | disableSockets(`w-graph-node-output:not([type='audio'])`) 70 | break 71 | default: 72 | disableSockets(`w-graph-node-output[type='trigger']`) 73 | } 74 | break 75 | } 76 | }) 77 | 78 | host.addEventListener('click', () => { 79 | if (linking === null) { 80 | return 81 | } 82 | host.querySelectorAll(`w-graph-node-output[disabled], w-graph-node-input[disabled]`).forEach(( 83 | /** @type {GraphNodeOutput | GraphNodeInput} */ outputOrInput, 84 | ) => { 85 | outputOrInput.disabled = false 86 | }) 87 | linking = null 88 | }) 89 | } 90 | -------------------------------------------------------------------------------- /src/audio-node-editor/__tests__/audio-node.js: -------------------------------------------------------------------------------- 1 | import { expect, test } from '@jest/globals' 2 | import { 3 | click, 4 | contextMenu, 5 | findFieldByLabel, 6 | findFieldInputByLabel, 7 | getSelectOption, 8 | setup, 9 | } from '../../testing/helpers' 10 | 11 | test('has no node by default', () => { 12 | const { getGraphNodes } = setup('Nodes') 13 | expect(getGraphNodes()).toHaveLength(0) 14 | }) 15 | 16 | test('opens context menu on node editor', () => { 17 | const { nodeEditor, getMenuItems } = setup('Nodes') 18 | contextMenu(nodeEditor) 19 | expect(getMenuItems().map((menuItem) => menuItem.textContent)).toEqual([ 20 | expect.stringContaining('Track'), 21 | expect.stringContaining('Schedule'), 22 | expect.stringContaining('Audio file'), 23 | expect.stringContaining('Oscillator'), 24 | expect.stringContaining('Constant'), 25 | expect.stringContaining('White noise'), 26 | expect.stringContaining('Gain'), 27 | expect.stringContaining('Biquad filter'), 28 | expect.stringContaining('Analyser'), 29 | expect.stringContaining('Audio destination'), 30 | ]) 31 | document.body.dispatchEvent(new MouseEvent('mousedown')) 32 | expect(getMenuItems()).toHaveLength(0) 33 | }) 34 | 35 | test('adds an oscillator node', () => { 36 | const { addAudioNode, getGraphNodes } = setup('Nodes') 37 | addAudioNode('Oscillator') 38 | expect(getGraphNodes()).toEqual([ 39 | expect.objectContaining({ 40 | textContent: expect.stringContaining('Oscillator'), 41 | selected: true, 42 | }), 43 | ]) 44 | }) 45 | 46 | test('adds an audio destination node', () => { 47 | const { addAudioNode, getGraphNodes } = setup('Nodes') 48 | addAudioNode('Audio destination') 49 | expect(getGraphNodes()).toEqual([ 50 | expect.objectContaining({ 51 | textContent: expect.stringContaining('Audio destination'), 52 | selected: true, 53 | }), 54 | ]) 55 | }) 56 | 57 | test('duplicates audio properties', () => { 58 | const { oscillatorMock, nodeEditor, getMenuItem, addAudioNode, getGraphNodes } = setup('Nodes') 59 | addAudioNode('Oscillator') 60 | const [oscillator1] = getGraphNodes() 61 | 62 | const typeField1 = findFieldByLabel(oscillator1, 'w-select', 'Type') 63 | typeField1.click() 64 | getSelectOption(typeField1, 'Sawtooth').click() 65 | 66 | const frequencyFieldInput1 = findFieldInputByLabel(oscillator1, 'w-number-field', 'Frequency') 67 | frequencyFieldInput1.valueAsNumber = 880 68 | frequencyFieldInput1.dispatchEvent(new InputEvent('input', { composed: true })) 69 | 70 | oscillatorMock.type = 'sine' 71 | oscillatorMock.frequency.value = 440 72 | 73 | click(oscillator1) 74 | contextMenu(oscillator1) 75 | getMenuItem('Duplicate').click() 76 | nodeEditor.dispatchEvent(new MouseEvent('mousemove')) 77 | click(nodeEditor) 78 | 79 | const oscillator2 = getGraphNodes()[1] 80 | const typeFieldInput2 = findFieldInputByLabel(oscillator2, 'w-select', 'Type') 81 | const frequencyFieldInput2 = findFieldInputByLabel(oscillator2, 'w-number-field', 'Frequency') 82 | 83 | expect(typeFieldInput2.value).toBe('sawtooth') 84 | expect(oscillatorMock.type).toBe('sawtooth') 85 | expect(frequencyFieldInput2.valueAsNumber).toBe(880) 86 | expect(oscillatorMock.frequency.value).toBe(880) 87 | }) 88 | -------------------------------------------------------------------------------- /src/shared/core/elevation.js: -------------------------------------------------------------------------------- 1 | import { css } from './element.js' 2 | 3 | const baselineColor = '0 0 0' 4 | const umbraOpacity = 0.2 5 | const penumbraOpacity = 0.14 6 | const ambientOpacity = 0.12 7 | 8 | const umbraMap = [ 9 | '0px 0px 0px 0px', 10 | '0px 2px 1px -1px', 11 | '0px 3px 1px -2px', 12 | '0px 3px 3px -2px', 13 | '0px 2px 4px -1px', 14 | '0px 3px 5px -1px', 15 | '0px 3px 5px -1px', 16 | '0px 4px 5px -2px', 17 | '0px 5px 5px -3px', 18 | '0px 5px 6px -3px', 19 | '0px 6px 6px -3px', 20 | '0px 6px 7px -4px', 21 | '0px 7px 8px -4px', 22 | '0px 7px 8px -4px', 23 | '0px 7px 9px -4px', 24 | '0px 8px 9px -5px', 25 | '0px 8px 10px -5px', 26 | '0px 8px 11px -5px', 27 | '0px 9px 11px -5px', 28 | '0px 9px 12px -6px', 29 | '0px 10px 13px -6px', 30 | '0px 10px 13px -6px', 31 | '0px 10px 14px -6px', 32 | '0px 11px 14px -7px', 33 | '0px 11px 15px -7px', 34 | ] 35 | 36 | const penumbraMap = [ 37 | '0px 0px 0px 0px', 38 | '0px 1px 1px 0px', 39 | '0px 2px 2px 0px', 40 | '0px 3px 4px 0px', 41 | '0px 4px 5px 0px', 42 | '0px 5px 8px 0px', 43 | '0px 6px 10px 0px', 44 | '0px 7px 10px 1px', 45 | '0px 8px 10px 1px', 46 | '0px 9px 12px 1px', 47 | '0px 10px 14px 1px', 48 | '0px 11px 15px 1px', 49 | '0px 12px 17px 2px', 50 | '0px 13px 19px 2px', 51 | '0px 14px 21px 2px', 52 | '0px 15px 22px 2px', 53 | '0px 16px 24px 2px', 54 | '0px 17px 26px 2px', 55 | '0px 18px 28px 2px', 56 | '0px 19px 29px 2px', 57 | '0px 20px 31px 3px', 58 | '0px 21px 33px 3px', 59 | '0px 22px 35px 3px', 60 | '0px 23px 36px 3px', 61 | '0px 24px 38px 3px', 62 | ] 63 | 64 | const ambientMap = [ 65 | '0px 0px 0px 0px', 66 | '0px 1px 3px 0px', 67 | '0px 1px 5px 0px', 68 | '0px 1px 8px 0px', 69 | '0px 1px 10px 0px', 70 | '0px 1px 14px 0px', 71 | '0px 1px 18px 0px', 72 | '0px 2px 16px 1px', 73 | '0px 3px 14px 2px', 74 | '0px 3px 16px 2px', 75 | '0px 4px 18px 3px', 76 | '0px 4px 20px 3px', 77 | '0px 5px 22px 4px', 78 | '0px 5px 24px 4px', 79 | '0px 5px 26px 4px', 80 | '0px 6px 28px 5px', 81 | '0px 6px 30px 5px', 82 | '0px 6px 32px 5px', 83 | '0px 7px 34px 6px', 84 | '0px 7px 36px 6px', 85 | '0px 8px 38px 7px', 86 | '0px 8px 40px 7px', 87 | '0px 8px 42px 7px', 88 | '0px 9px 44px 8px', 89 | '0px 9px 46px 8px', 90 | ] 91 | 92 | /** 93 | * @param {number} z 94 | */ 95 | function shadow(z) { 96 | return [ 97 | `${umbraMap[z]} rgba(${baselineColor} / ${umbraOpacity})`, 98 | `${penumbraMap[z]} rgba(${baselineColor} / ${penumbraOpacity})`, 99 | `${ambientMap[z]} rgba(${baselineColor} / ${ambientOpacity})`, 100 | ].join(', ') 101 | } 102 | 103 | /** 104 | * @param {number} z 105 | */ 106 | function overlay(z) { 107 | const overlayOpacity = z === 0 ? 0 : Math.round(4.5 * Math.log1p(z) + 2) / 100 108 | const overlayColor = `rgba(var(--color-on-surface) / ${overlayOpacity})` 109 | return `linear-gradient(${overlayColor}, ${overlayColor})` 110 | } 111 | 112 | /** 113 | * @param {number} z 114 | */ 115 | export default function elevation(z) { 116 | return css` 117 | z-index: ${z}; 118 | box-shadow: var(--shadow, ${shadow(z)}); 119 | background-image: var(--overlay, ${overlay(z)}); 120 | ` 121 | } 122 | -------------------------------------------------------------------------------- /src/audio-node-editor/node-audio-file.js: -------------------------------------------------------------------------------- 1 | import { defineCustomElement, html } from '../shared/core/element.js' 2 | import useAudioContext from './use-audio-context.js' 3 | import { bindAudioInput, bindAudioOutput } from './use-audio-link.js' 4 | 5 | /** 6 | * @typedef {import('../shared/base/file.js').default} FileInput 7 | * @typedef {import('../shared/base/file.js').FileLoadEvent} FileLoadEvent 8 | */ 9 | 10 | /** @type {Map} */ 11 | export const audioBuffers = new Map() 12 | 13 | /** 14 | * @param {ArrayBuffer} arrayBuffer 15 | */ 16 | async function computeHash(arrayBuffer) { 17 | const digest = await crypto.subtle.digest('SHA-256', arrayBuffer) 18 | return [...new Uint8Array(digest)].map((v) => v.toString(16).padStart(2, '0')).join('') 19 | } 20 | 21 | export default defineCustomElement('node-audio-file', { 22 | template: html` 23 | 24 | Audio file 25 | Output 26 | 27 | Trigger 28 | 29 | `, 30 | shadow: false, 31 | properties: { 32 | name: String, 33 | hash: String, 34 | }, 35 | setup({ host, connected, observe }) { 36 | const audioContext = useAudioContext() 37 | 38 | /** @type {Set} */ 39 | const destinations = new Set() 40 | 41 | /** @type {AudioBuffer} */ 42 | let audioBuffer = null 43 | 44 | /** @type {AudioBufferSourceNode} */ 45 | let bufferSource = null 46 | 47 | const audioFile = { 48 | /** 49 | * @param {number} time 50 | */ 51 | trigger(time) { 52 | if (audioBuffer === null) { 53 | return 54 | } 55 | if (bufferSource !== null) { 56 | bufferSource.stop(time) 57 | } 58 | bufferSource = audioContext.createBufferSource() 59 | bufferSource.buffer = audioBuffer 60 | destinations.forEach((destination) => bufferSource.connect(destination)) 61 | bufferSource.start(time) 62 | }, 63 | 64 | /** 65 | * @param {AudioNode} destination 66 | */ 67 | connect(destination) { 68 | destinations.add(destination) 69 | }, 70 | 71 | disconnect() { 72 | destinations.clear() 73 | }, 74 | } 75 | 76 | connected(() => { 77 | /** @type {FileInput} */ 78 | const fileInput = host.querySelector('w-file') 79 | 80 | fileInput.name = host.name 81 | audioBuffer = audioBuffers.get(host.hash) 82 | 83 | bindAudioOutput(host.querySelector('w-graph-node-output'), audioFile) 84 | bindAudioInput(host.querySelector('w-graph-node-input'), audioFile) 85 | 86 | observe('name', () => { 87 | fileInput.name = host.name 88 | }) 89 | 90 | observe('hash', () => { 91 | audioBuffer = audioBuffers.get(host.hash) 92 | }) 93 | 94 | fileInput.addEventListener('file-load', async (event) => { 95 | const { name, content } = /** @type {FileLoadEvent} */ (event).detail 96 | host.name = name 97 | 98 | // Do not update host.hash before decoding audio data 99 | const hash = await computeHash(content) 100 | if (!audioBuffers.has(hash)) { 101 | audioBuffers.set(hash, await audioContext.decodeAudioData(content)) 102 | } 103 | host.hash = hash 104 | }) 105 | }) 106 | }, 107 | }) 108 | -------------------------------------------------------------------------------- /src/audio-tracker/audio-track.js: -------------------------------------------------------------------------------- 1 | import { css, defineCustomElement, html } from '../shared/core/element.js' 2 | import typography from '../shared/core/typography.js' 3 | import { nextId } from '../shared/helpers/id.js' 4 | import { deregisterAudioTrack, registerAudioTrack } from './use-audio-tracker.js' 5 | 6 | const link = document.createElement('link') 7 | link.href = 'https://fonts.googleapis.com/css2?family=Roboto+Mono:wght@300;400;500&display=swap' 8 | link.rel = 'stylesheet' 9 | document.head.appendChild(link) 10 | 11 | export default defineCustomElement('audio-track', { 12 | styles: css` 13 | :host, 14 | .border { 15 | border: 1px solid rgba(var(--color-on-surface) / 0.42); 16 | } 17 | 18 | :host { 19 | position: relative; 20 | border-top: none; 21 | border-radius: 0 0 4px 4px; 22 | margin: 33px 8px 0 0; 23 | padding-bottom: 8px; 24 | display: flex; 25 | flex-direction: column; 26 | } 27 | 28 | .overlay { 29 | position: sticky; 30 | top: 0; 31 | right: 0; 32 | left: 0; 33 | height: 17px; 34 | margin: -33px -1px 0 -1px; 35 | padding-top: 16px; 36 | background-color: rgb(var(--color-surface)); 37 | } 38 | 39 | .border { 40 | position: relative; 41 | height: 16px; 42 | border-bottom: none; 43 | border-radius: 4px 4px 0 0; 44 | } 45 | 46 | :host(:hover), 47 | :host(:hover) .border { 48 | border-color: rgba(var(--color-on-surface) / var(--text-high-emphasis)); 49 | } 50 | 51 | :host::before, 52 | .border::before { 53 | content: ''; 54 | position: absolute; 55 | top: -1px; 56 | right: -1px; 57 | bottom: -1px; 58 | left: -1px; 59 | border: 2px solid transparent; 60 | pointer-events: none; 61 | transition: border-color 200ms var(--easing-standard); 62 | } 63 | 64 | :host::before { 65 | top: 0; 66 | border-top: none; 67 | border-radius: 0 0 4px 4px; 68 | } 69 | 70 | .border::before { 71 | bottom: 0; 72 | border-bottom: none; 73 | border-radius: 4px 4px 0 0; 74 | } 75 | 76 | :host(:focus-within)::before, 77 | :host(:focus-within) .border::before { 78 | border-color: rgb(var(--color-primary)); 79 | } 80 | 81 | label { 82 | position: absolute; 83 | top: 0; 84 | left: 50%; 85 | transform: translate(-50%, -50%); 86 | padding: 0 4px; 87 | background-color: rgb(var(--color-surface)); 88 | color: rgba(var(--color-on-surface) / var(--text-medium-emphasis)); 89 | user-select: none; 90 | transition: color 200ms var(--easing-standard); 91 | ${typography('caption')} 92 | } 93 | 94 | :host(:focus-within) label { 95 | color: rgba(var(--color-primary) / var(--text-high-emphasis)); 96 | } 97 | `, 98 | template: html` 99 |
100 |
101 | 102 |
103 |
104 | 105 | `, 106 | properties: { 107 | label: String, 108 | }, 109 | setup({ host, connected, disconnected, observe }) { 110 | const label = host.shadowRoot.querySelector('label') 111 | 112 | connected(() => { 113 | host.label = `${nextId('track')}` 114 | registerAudioTrack(host) 115 | }) 116 | 117 | disconnected(() => { 118 | deregisterAudioTrack(host) 119 | }) 120 | 121 | observe('label', () => { 122 | label.textContent = host.label 123 | }) 124 | }, 125 | }) 126 | -------------------------------------------------------------------------------- /src/shared/node-editor/use-graph-link.js: -------------------------------------------------------------------------------- 1 | import useNodeEditorMousePosition from './use-node-editor-mouse-position.js' 2 | 3 | /** 4 | * @typedef {import('./node-editor.js').default} NodeEditor 5 | * @typedef {import('./graph-link.js').default} GraphLink 6 | * 7 | * @typedef {object} GraphLinkEventDetail 8 | * @property {string} [from] 9 | * @property {string} [to] 10 | * 11 | * @typedef {CustomEvent} GraphLinkEvent 12 | */ 13 | 14 | /** 15 | * @param {NodeEditor} host 16 | */ 17 | export default function useGraphLink(host) { 18 | /** @type {GraphLink} */ 19 | let graphLink = null 20 | 21 | /** @type {boolean} */ 22 | let isConnected 23 | 24 | const getNodeEditorMousePosition = useNodeEditorMousePosition(host) 25 | 26 | host.addEventListener('graph-link-start', (/** @type {GraphLinkEvent} */ event) => { 27 | if (event.detail.from) { 28 | graphLink = /** @type {GraphLink} */ (document.createElement('w-graph-link')) 29 | graphLink.from = event.detail.from 30 | host.linking = 'output' 31 | isConnected = false 32 | } else if (event.detail.to) { 33 | graphLink = /** @type {GraphLink} */ (host.querySelector(`w-graph-link[to='${event.detail.to}']`)) 34 | if (graphLink) { 35 | // Moves at the end of the host DOM to allow selecting 36 | // another link after reconnecting this one 37 | graphLink.remove() 38 | host.linking = 'output' 39 | isConnected = true 40 | } else { 41 | graphLink = /** @type {GraphLink} */ (document.createElement('w-graph-link')) 42 | graphLink.to = event.detail.to 43 | host.linking = 'input' 44 | isConnected = false 45 | } 46 | } 47 | graphLink.linking = true 48 | host.appendChild(graphLink) 49 | }) 50 | 51 | host.addEventListener('graph-link-end', (/** @type {GraphLinkEvent} */ event) => { 52 | if (!graphLink) { 53 | return 54 | } 55 | if (host.linking === 'output') { 56 | graphLink.to = event.detail.to 57 | graphLink.toX = null 58 | graphLink.toY = null 59 | } else if (host.linking === 'input') { 60 | graphLink.from = event.detail.from 61 | graphLink.fromX = null 62 | graphLink.fromY = null 63 | } 64 | }) 65 | 66 | host.addEventListener('mousemove', (event) => { 67 | if (!graphLink) { 68 | return 69 | } 70 | if (isConnected) { 71 | host.dispatchEvent( 72 | new CustomEvent('graph-link-disconnect', { 73 | detail: { from: graphLink.from, to: graphLink.to }, 74 | }), 75 | ) 76 | } 77 | const { x, y } = getNodeEditorMousePosition(event) 78 | if (host.linking === 'output') { 79 | graphLink.to = null 80 | graphLink.toX = x 81 | graphLink.toY = y 82 | } else if (host.linking === 'input') { 83 | graphLink.from = null 84 | graphLink.fromX = x 85 | graphLink.fromY = y 86 | } 87 | isConnected = false 88 | }) 89 | 90 | host.addEventListener('mouseup', () => { 91 | if (!graphLink) { 92 | return 93 | } 94 | graphLink.remove() 95 | }) 96 | 97 | host.addEventListener('click', () => { 98 | if (!graphLink) { 99 | return 100 | } 101 | if (!isConnected && graphLink.from && graphLink.to) { 102 | const existingGraphLinks = host.querySelectorAll(`w-graph-link[from='${graphLink.from}'][to='${graphLink.to}']`) 103 | if (existingGraphLinks.length > 1) { 104 | graphLink.remove() 105 | } else { 106 | host.dispatchEvent( 107 | new CustomEvent('graph-link-connect', { 108 | detail: { from: graphLink.from, to: graphLink.to }, 109 | }), 110 | ) 111 | } 112 | } 113 | host.linking = null 114 | graphLink.linking = false 115 | graphLink = null 116 | }) 117 | } 118 | -------------------------------------------------------------------------------- /src/audio-node-editor/__tests__/node-oscillator.js: -------------------------------------------------------------------------------- 1 | import { expect, test } from '@jest/globals' 2 | import { 3 | contextMenu, 4 | findFieldByLabel, 5 | findFieldInputByLabel, 6 | getSelectOption, 7 | getSelectOptions, 8 | setup, 9 | } from '../../testing/helpers' 10 | 11 | test('starts and stops', () => { 12 | const { oscillatorMock, getMenuItem, addAudioNode, getGraphNodes } = setup('Nodes') 13 | addAudioNode('Oscillator') 14 | 15 | expect(oscillatorMock.start).toHaveBeenCalledTimes(1) 16 | 17 | const [oscillator] = getGraphNodes() 18 | contextMenu(oscillator) 19 | getMenuItem('Delete').click() 20 | 21 | expect(oscillatorMock.stop).toHaveBeenCalledTimes(1) 22 | }) 23 | 24 | test('changes type', () => { 25 | const { oscillatorMock, addAudioNode, getGraphNodes } = setup('Nodes') 26 | addAudioNode('Oscillator') 27 | const [oscillator] = getGraphNodes() 28 | const typeField = findFieldByLabel(oscillator, 'w-select', 'Type') 29 | const typeFieldInput = typeField.shadowRoot.querySelector('w-text-field').shadowRoot.querySelector('input') 30 | 31 | expect(typeFieldInput.value).toBe('sine') 32 | typeField.click() 33 | expect(getSelectOptions(typeField).map((option) => option.textContent)).toEqual([ 34 | 'Sine', 35 | 'Square', 36 | 'Sawtooth', 37 | 'Triangle', 38 | ]) 39 | document.body.dispatchEvent(new MouseEvent('mousedown')) 40 | expect(getSelectOptions(typeField)).toHaveLength(0) 41 | 42 | typeField.click() 43 | getSelectOption(typeField, 'Square').click() 44 | expect(typeFieldInput.value).toBe('square') 45 | expect(oscillatorMock.type).toBe('square') 46 | 47 | typeField.click() 48 | getSelectOption(typeField, 'Sawtooth').click() 49 | expect(typeFieldInput.value).toBe('sawtooth') 50 | expect(oscillatorMock.type).toBe('sawtooth') 51 | 52 | typeField.click() 53 | getSelectOption(typeField, 'Triangle').click() 54 | expect(typeFieldInput.value).toBe('triangle') 55 | expect(oscillatorMock.type).toBe('triangle') 56 | 57 | typeField.click() 58 | getSelectOption(typeField, 'Sine').click() 59 | expect(typeFieldInput.value).toBe('sine') 60 | expect(oscillatorMock.type).toBe('sine') 61 | }) 62 | 63 | test('changes frequency', () => { 64 | const { oscillatorMock, addAudioNode, getGraphNodes } = setup('Nodes') 65 | addAudioNode('Oscillator') 66 | const [oscillator] = getGraphNodes() 67 | const frequencyFieldInput = findFieldInputByLabel(oscillator, 'w-number-field', 'Frequency') 68 | 69 | expect(frequencyFieldInput.valueAsNumber).toBe(440) 70 | 71 | frequencyFieldInput.valueAsNumber = 880 72 | frequencyFieldInput.dispatchEvent(new InputEvent('input', { composed: true })) 73 | 74 | expect(oscillatorMock.frequency.value).toBe(880) 75 | }) 76 | 77 | test('connects to frequency', () => { 78 | const { oscillatorMock, addAudioNode, getGraphNodes, addGraphLink } = setup('Nodes') 79 | addAudioNode('Oscillator') 80 | addAudioNode('Oscillator') 81 | const [oscillator1, oscillator2] = getGraphNodes() 82 | addGraphLink(oscillator1, oscillator2, 'Frequency') 83 | 84 | expect(oscillatorMock.connect).toHaveBeenCalledTimes(1) 85 | expect(oscillatorMock.connect).toHaveBeenCalledWith(oscillatorMock.frequency) 86 | }) 87 | 88 | test('changes detune', () => { 89 | const { oscillatorMock, addAudioNode, getGraphNodes } = setup('Nodes') 90 | addAudioNode('Oscillator') 91 | const [oscillator] = getGraphNodes() 92 | const detuneFieldInput = findFieldInputByLabel(oscillator, 'w-number-field', 'Detune') 93 | 94 | expect(detuneFieldInput.valueAsNumber).toBe(0) 95 | 96 | detuneFieldInput.valueAsNumber = 1 97 | detuneFieldInput.dispatchEvent(new InputEvent('input', { composed: true })) 98 | 99 | expect(oscillatorMock.detune.value).toBe(1) 100 | }) 101 | 102 | test('connects to detune', () => { 103 | const { oscillatorMock, addAudioNode, getGraphNodes, addGraphLink } = setup('Nodes') 104 | addAudioNode('Oscillator') 105 | addAudioNode('Oscillator') 106 | const [oscillator1, oscillator2] = getGraphNodes() 107 | addGraphLink(oscillator1, oscillator2, 'Detune') 108 | 109 | expect(oscillatorMock.connect).toHaveBeenCalledTimes(1) 110 | expect(oscillatorMock.connect).toHaveBeenCalledWith(oscillatorMock.detune) 111 | }) 112 | -------------------------------------------------------------------------------- /src/audio-tracker/use-audio-tracker.js: -------------------------------------------------------------------------------- 1 | import useAudioContext from '../audio-node-editor/use-audio-context.js' 2 | import { html } from '../shared/core/element.js' 3 | 4 | /** 5 | * @typedef {import('./audio-tracker.js').default} AudioTracker 6 | * @typedef {import('./audio-track.js').default} AudioTrack 7 | * @typedef {import('./track-effect.js').default} TrackEffect 8 | * @typedef {import('../audio-node-editor/node-track.js').Track} Track 9 | * @typedef {import('../shared/base/select.js').default} Select 10 | * @typedef {import('../shared/base/menu-item.js').default} MenuItem 11 | */ 12 | 13 | /** @type {Map} */ 14 | const audioTracksByTrackLabel = new Map() 15 | 16 | /** @type {Map} */ 17 | const tracksByField = new Map() 18 | 19 | export const defaultTempo = 120 20 | export const defaultLines = 64 21 | export const defaultLinesPerBeat = 4 22 | 23 | /** 24 | * @param {AudioTrack} audioTrack 25 | */ 26 | export function registerAudioTrack(audioTrack) { 27 | audioTracksByTrackLabel.set(audioTrack.label, audioTrack) 28 | const template = document.createElement('template') 29 | template.innerHTML = html`${audioTrack.label}` 30 | tracksByField.forEach((_, selectField) => { 31 | selectField.appendChild(template.content.cloneNode(true)) 32 | }) 33 | } 34 | 35 | /** 36 | * @param {AudioTrack} audioTrack 37 | */ 38 | export function deregisterAudioTrack(audioTrack) { 39 | audioTracksByTrackLabel.delete(audioTrack.label) 40 | tracksByField.forEach((_, selectField) => { 41 | if (selectField.value === audioTrack.label) { 42 | selectField.value = null 43 | } 44 | const menuItem = selectField.querySelector(`w-menu-item[value='${audioTrack.label}']`) 45 | menuItem.remove() 46 | }) 47 | } 48 | 49 | /** 50 | * @param {Select} selectField 51 | * @param {Track} track 52 | */ 53 | export function bindAudioTrack(selectField, track) { 54 | audioTracksByTrackLabel.forEach((_, trackLabel) => { 55 | const menuItem = /** @type {MenuItem} */ (document.createElement('w-menu-item')) 56 | menuItem.textContent = trackLabel 57 | menuItem.value = trackLabel 58 | selectField.appendChild(menuItem) 59 | }) 60 | tracksByField.set(selectField, track) 61 | } 62 | 63 | /** 64 | * @param {Select} selectField 65 | */ 66 | export function unbindAudioTrack(selectField) { 67 | tracksByField.delete(selectField) 68 | } 69 | 70 | /** 71 | * @param {AudioTracker} host 72 | */ 73 | export default function useAudioTracker(host) { 74 | const audioContext = useAudioContext() 75 | 76 | host.tempo = defaultTempo 77 | host.lines = defaultLines 78 | host.linesPerBeat = defaultLinesPerBeat 79 | 80 | /** @type {number} */ 81 | let timeoutID = null 82 | 83 | /** @type {number} */ 84 | let triggerTime 85 | 86 | /** @type {number} */ 87 | let line 88 | 89 | function trigger() { 90 | tracksByField.forEach((track, selectField) => { 91 | const trackLabel = selectField.value 92 | if (trackLabel === null) { 93 | return 94 | } 95 | /** @type {TrackEffect} */ 96 | const trackEffect = audioTracksByTrackLabel.get(trackLabel).querySelector(`track-effect:nth-of-type(${line})`) 97 | const value = trackEffect.value 98 | if (value === null) { 99 | return 100 | } 101 | track.trigger(triggerTime) 102 | }) 103 | } 104 | 105 | function scheduleTrigger() { 106 | while (triggerTime < audioContext.currentTime + 0.1) { 107 | if (line > host.lines) { 108 | line = 1 109 | } 110 | trigger() 111 | const secondsPerBeat = 60 / host.tempo 112 | triggerTime += secondsPerBeat / host.linesPerBeat 113 | line++ 114 | } 115 | timeoutID = window.setTimeout(scheduleTrigger, 25) 116 | } 117 | 118 | return { 119 | startAudioTracker() { 120 | triggerTime = audioContext.currentTime 121 | line = 1 122 | scheduleTrigger() 123 | }, 124 | 125 | stopAudioTracker() { 126 | if (timeoutID === null) { 127 | return 128 | } 129 | window.clearTimeout(timeoutID) 130 | timeoutID = null 131 | }, 132 | 133 | isAudioTrackerStarted() { 134 | return timeoutID !== null 135 | }, 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /src/audio-tracker/__tests__/audio-tracker.js: -------------------------------------------------------------------------------- 1 | import { expect, test } from '@jest/globals' 2 | import '../../index' 3 | import { contextMenu, findFieldInputByLabel, setup } from '../../testing/helpers' 4 | 5 | /** 6 | * @typedef {import('../track-effect').default} TrackEffect 7 | */ 8 | 9 | test('has no track by default', () => { 10 | const { getAudioTracks } = setup('Tracks') 11 | expect(getAudioTracks()).toHaveLength(0) 12 | }) 13 | 14 | test('adds tracks', () => { 15 | const { addAudioTrack, getAudioTracks } = setup('Tracks') 16 | addAudioTrack() 17 | expect(getAudioTracks().map((audioTrack) => audioTrack.shadowRoot.querySelector('label').textContent)).toEqual(['1']) 18 | addAudioTrack() 19 | expect(getAudioTracks().map((audioTrack) => audioTrack.shadowRoot.querySelector('label').textContent)).toEqual([ 20 | '1', 21 | '2', 22 | ]) 23 | }) 24 | 25 | test('deletes tracks', () => { 26 | const { getMenuItem, addAudioTrack, getAudioTracks } = setup('Tracks') 27 | addAudioTrack() 28 | addAudioTrack() 29 | const audioTracks = getAudioTracks() 30 | const [audioTrack1, audioTrack2] = audioTracks 31 | 32 | expect(audioTracks).toHaveLength(2) 33 | 34 | contextMenu(audioTrack1) 35 | getMenuItem('Delete track').click() 36 | 37 | const remainingAudioGracks = getAudioTracks() 38 | expect(remainingAudioGracks).toHaveLength(1) 39 | expect(remainingAudioGracks[0]).toBe(audioTrack2) 40 | }) 41 | 42 | test('updates lines', () => { 43 | const { audioTracker, addAudioTrack, getAudioTracks } = setup('Tracks') 44 | addAudioTrack() 45 | const [audioTrack1] = getAudioTracks() 46 | const controls = audioTracker.shadowRoot.querySelector('aside') 47 | const linesFieldInput = findFieldInputByLabel(controls, 'w-number-field', 'Lines') 48 | 49 | expect(linesFieldInput.valueAsNumber).toBe(64) 50 | expect(audioTrack1.querySelectorAll('track-effect')).toHaveLength(64) 51 | 52 | linesFieldInput.valueAsNumber = 32 53 | linesFieldInput.dispatchEvent(new InputEvent('input', { composed: true })) 54 | 55 | expect(audioTrack1.querySelectorAll('track-effect')).toHaveLength(32) 56 | 57 | linesFieldInput.valueAsNumber = 48 58 | linesFieldInput.dispatchEvent(new InputEvent('input', { composed: true })) 59 | 60 | expect(audioTrack1.querySelectorAll('track-effect')).toHaveLength(48) 61 | 62 | addAudioTrack() 63 | const audioTrack2 = getAudioTracks()[1] 64 | 65 | expect(audioTrack2.querySelectorAll('track-effect')).toHaveLength(48) 66 | }) 67 | 68 | test('updates lines per beat', () => { 69 | const { audioTracker, addAudioTrack, getAudioTracks } = setup('Tracks') 70 | addAudioTrack() 71 | const [audioTrack] = getAudioTracks() 72 | const controls = audioTracker.shadowRoot.querySelector('aside') 73 | const linesFieldInput = findFieldInputByLabel(controls, 'w-number-field', 'Lines') 74 | const linesPerBeatFieldInput = findFieldInputByLabel(controls, 'w-number-field', 'Lines per beat') 75 | 76 | linesFieldInput.valueAsNumber = 8 77 | linesFieldInput.dispatchEvent(new InputEvent('input', { composed: true })) 78 | 79 | let trackEffects = [.../** @type {NodeListOf} */ (audioTrack.querySelectorAll('track-effect'))] 80 | 81 | expect(linesPerBeatFieldInput.valueAsNumber).toBe(4) 82 | expect(trackEffects.map((trackEffect) => trackEffect.beat)).toEqual([ 83 | true, 84 | false, 85 | false, 86 | false, 87 | true, 88 | false, 89 | false, 90 | false, 91 | ]) 92 | 93 | linesPerBeatFieldInput.valueAsNumber = 2 94 | linesPerBeatFieldInput.dispatchEvent(new InputEvent('input', { composed: true })) 95 | 96 | expect(trackEffects.map((trackEffect) => trackEffect.beat)).toEqual([ 97 | true, 98 | false, 99 | true, 100 | false, 101 | true, 102 | false, 103 | true, 104 | false, 105 | ]) 106 | 107 | linesFieldInput.valueAsNumber = 10 108 | linesFieldInput.dispatchEvent(new InputEvent('input', { composed: true })) 109 | trackEffects = [.../** @type {NodeListOf} */ (audioTrack.querySelectorAll('track-effect'))] 110 | 111 | expect(trackEffects.map((trackEffect) => trackEffect.beat)).toEqual([ 112 | true, 113 | false, 114 | true, 115 | false, 116 | true, 117 | false, 118 | true, 119 | false, 120 | true, 121 | false, 122 | ]) 123 | }) 124 | -------------------------------------------------------------------------------- /src/audio-node-editor/use-audio-node.js: -------------------------------------------------------------------------------- 1 | import { bindAudioInput } from './use-audio-link.js' 2 | 3 | /** 4 | * @typedef {import('../shared/base/select.js').default} Select 5 | * @typedef {import('../shared/base/number-field.js').default} NumberField 6 | * @typedef {import('../shared/core/element.js').PropertyTypes} PropertyTypes 7 | */ 8 | 9 | /** 10 | * @template {PropertyTypes} T 11 | * @typedef {import('../shared/core/element.js').SetupOptions} SetupOptions 12 | */ 13 | 14 | /** 15 | * @template {PropertyTypes} T 16 | * @typedef {import('../shared/core/element.js').Setup} Setup 17 | */ 18 | 19 | /** 20 | * @template {PropertyTypes} T, U 21 | * @typedef {{ [K in keyof T]: T[K] extends U ? K : never }} FilterProperties 22 | */ 23 | 24 | /** 25 | * @template {PropertyTypes} T, U 26 | * @typedef {FilterProperties[keyof T]} FilterPropertyNames 27 | */ 28 | 29 | /** 30 | * @template {PropertyTypes} T 31 | * @callback UseProperty 32 | * @param {NumberField | Select} field 33 | * @param {FilterPropertyNames} propertyName 34 | * @returns {void} 35 | */ 36 | 37 | /** 38 | * @template {PropertyTypes} T 39 | * @callback UseAudioProperty 40 | * @param {Select} field 41 | * @param {AudioNode} audioNode 42 | * @param {FilterPropertyNames} propertyName 43 | * @returns {void} 44 | */ 45 | 46 | /** 47 | * @template {PropertyTypes} T 48 | * @callback UseAudioParam 49 | * @param {NumberField} field 50 | * @param {AudioNode} audioNode 51 | * @param {FilterPropertyNames} propertyName 52 | * @returns {void} 53 | */ 54 | 55 | /** 56 | * @template {PropertyTypes} T 57 | * @typedef {object} UseAudioNodeOptions 58 | * @property {UseProperty} useProperty 59 | * @property {UseAudioProperty} useAudioProperty 60 | * @property {UseAudioParam} useAudioParam 61 | */ 62 | 63 | /** 64 | * @template {PropertyTypes} T 65 | * @typedef {SetupOptions & UseAudioNodeOptions} CreateAudioNodeOptions 66 | */ 67 | 68 | /** 69 | * @template {PropertyTypes} T 70 | * @param {(options: CreateAudioNodeOptions) => void} setup 71 | * @returns {Setup} 72 | */ 73 | export default function createAudioNode(setup) { 74 | return function (options) { 75 | const { host, observe } = options 76 | setup({ 77 | ...options, 78 | useProperty(field, propertyName) { 79 | field.value = /** @type {number | string} */ (host[propertyName]) 80 | 81 | observe(propertyName, () => { 82 | field.value = /** @type {number | string} */ (host[propertyName]) 83 | }) 84 | 85 | field.addEventListener('input', () => { 86 | host[propertyName] = /** @type {any} */ (field.value) 87 | }) 88 | }, 89 | useAudioProperty(field, /** @type {any} */ audioNode, propertyName) { 90 | if (host.hasAttribute(/** @type {string} */ (propertyName))) { 91 | audioNode[propertyName] = host[propertyName] 92 | } else { 93 | host[propertyName] = audioNode[propertyName] 94 | } 95 | field.value = audioNode[propertyName] 96 | 97 | observe(propertyName, () => { 98 | field.value = /** @type {string} */ (host[propertyName]) 99 | audioNode[propertyName] = host[propertyName] 100 | }) 101 | 102 | field.addEventListener('input', () => { 103 | host[propertyName] = /** @type {any} */ (field.value) 104 | }) 105 | }, 106 | useAudioParam(numberField, /** @type {any} */ audioNode, propertyName) { 107 | /** @type {AudioParam} */ 108 | const audioParam = audioNode[propertyName] 109 | 110 | if (host.hasAttribute(/** @type {string} */ (propertyName))) { 111 | audioParam.value = /** @type {number} */ (host[propertyName]) 112 | } else { 113 | host[propertyName] = /** @type {any} */ (audioParam.value) 114 | } 115 | numberField.value = audioParam.value 116 | bindAudioInput(numberField.closest('w-graph-node-input'), audioParam) 117 | 118 | observe(propertyName, () => { 119 | numberField.value = /** @type {number} */ (host[propertyName]) 120 | audioNode[propertyName].value = host[propertyName] 121 | }) 122 | 123 | numberField.addEventListener('input', () => { 124 | host[propertyName] = /** @type {any} */ (numberField.value) 125 | }) 126 | }, 127 | }) 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /src/helpers/export-file.js: -------------------------------------------------------------------------------- 1 | import { audioBuffers } from '../audio-node-editor/node-audio-file.js' 2 | 3 | /** 4 | * @typedef {import('../shared/node-editor/graph-node.js').default} GraphNode 5 | * @typedef {import('../shared/node-editor/graph-link.js').default} GraphLink 6 | * @typedef {import('../audio-node-editor/node-audio-file.js').default} NodeAudioFile 7 | * @typedef {import('../audio-tracker/audio-tracker.js').default} AudioTracker 8 | * @typedef {import('../audio-tracker/audio-track.js').default} AudioTrack 9 | * @typedef {import('../audio-tracker/track-effect.js').default} TrackEffect 10 | * @typedef {import('./file-helper.js').FileContent} FileContent 11 | */ 12 | 13 | /** 14 | * @param {NamedNodeMap} attributes 15 | */ 16 | function exportAttributes(attributes) { 17 | return [...attributes].reduce( 18 | (attributes, { name, value }) => 19 | Object.assign(attributes, { 20 | [name]: value, 21 | }), 22 | {}, 23 | ) 24 | } 25 | 26 | /** 27 | * @param {HTMLElement} audioNodeEditor 28 | */ 29 | function exportNodes(audioNodeEditor) { 30 | return [.../** @type {NodeListOf} */ (audioNodeEditor.shadowRoot.querySelectorAll('w-graph-node'))].map( 31 | (graphNode) => ({ 32 | name: graphNode.parentElement.tagName.toLowerCase(), 33 | x: graphNode.x, 34 | y: graphNode.y, 35 | attributes: exportAttributes(graphNode.parentElement.attributes), 36 | outputs: [...graphNode.querySelectorAll('w-graph-node-output')].map((output) => output.id), 37 | inputs: [...graphNode.querySelectorAll('w-graph-node-input')].map((input) => input.id), 38 | }), 39 | ) 40 | } 41 | 42 | /** 43 | * @param {HTMLElement} audioNodeEditor 44 | */ 45 | function exportLinks(audioNodeEditor) { 46 | return [ 47 | .../** @type {NodeListOf} */ (audioNodeEditor.shadowRoot.querySelectorAll('w-graph-link')), 48 | ].map(({ from, to }) => ({ from, to })) 49 | } 50 | 51 | /** 52 | * @param {HTMLElement} audioTrack 53 | */ 54 | function exportEffects(audioTrack) { 55 | return [.../** @type {NodeListOf} */ (audioTrack.querySelectorAll('track-effect'))].reduce( 56 | (effects, trackEffect, index) => { 57 | if (trackEffect.value === null) { 58 | return effects 59 | } else { 60 | return Object.assign(effects, { [index]: trackEffect.value }) 61 | } 62 | }, 63 | {}, 64 | ) 65 | } 66 | 67 | /** 68 | * @param {AudioTracker} audioTracker 69 | */ 70 | function exportTracker(audioTracker) { 71 | return { 72 | tempo: audioTracker.tempo, 73 | lines: audioTracker.lines, 74 | linesPerBeat: audioTracker.linesPerBeat, 75 | } 76 | } 77 | 78 | /** 79 | * @param {HTMLElement} audioTracker 80 | */ 81 | function exportTracks(audioTracker) { 82 | return [.../** @type {NodeListOf} */ (audioTracker.shadowRoot.querySelectorAll('audio-track'))].map( 83 | (audioTrack) => ({ 84 | label: audioTrack.label, 85 | effects: exportEffects(audioTrack), 86 | }), 87 | ) 88 | } 89 | /** 90 | * @param {HTMLElement} audioNodeEditor 91 | */ 92 | function exportAudioFiles(audioNodeEditor) { 93 | const audioFiles = [ 94 | .../** @type {NodeListOf} */ (audioNodeEditor.shadowRoot.querySelectorAll('node-audio-file')), 95 | ] 96 | const hashes = [...new Set(audioFiles.map((audioFile) => audioFile.hash).filter((hash) => hash))] 97 | return hashes.map((hash) => { 98 | const audioBuffer = audioBuffers.get(hash) 99 | return { 100 | hash, 101 | length: audioBuffer.length, 102 | sampleRate: audioBuffer.sampleRate, 103 | channels: Array.from({ length: audioBuffer.numberOfChannels }, (_, channel) => { 104 | const channelData = new Uint8Array(audioBuffer.getChannelData(channel).buffer) 105 | return btoa([...channelData].map((codeUnit) => String.fromCharCode(codeUnit)).join('')) 106 | }), 107 | } 108 | }) 109 | } 110 | 111 | /** 112 | * @param {AudioTracker} audioTracker 113 | * @param {HTMLElement} audioNodeEditor 114 | * @returns {FileContent} 115 | */ 116 | export default function exportFile(audioTracker, audioNodeEditor) { 117 | return { 118 | nodeEditor: exportAttributes(audioNodeEditor.shadowRoot.querySelector('w-node-editor').attributes), 119 | nodes: exportNodes(audioNodeEditor), 120 | links: exportLinks(audioNodeEditor), 121 | tracker: exportTracker(audioTracker), 122 | tracks: exportTracks(audioTracker), 123 | audioFiles: exportAudioFiles(audioNodeEditor), 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /src/audio-node-editor/node-analyser.js: -------------------------------------------------------------------------------- 1 | import { defineCustomElement, html } from '../shared/core/element.js' 2 | import { nextId } from '../shared/helpers/id.js' 3 | import useAudioContext from './use-audio-context.js' 4 | import { bindAudioInput, bindAudioOutput } from './use-audio-link.js' 5 | 6 | const period = 2 7 | 8 | const colors = [ 9 | [0, 0, 128, 0], 10 | [0, 0, 128, 255], 11 | [0, 128, 255, 255], 12 | [0, 255, 0, 255], 13 | [255, 255, 0, 255], 14 | [255, 128, 0, 255], 15 | [255, 0, 0, 255], 16 | ] 17 | 18 | /** 19 | * @param {number} value 20 | */ 21 | function getColor(value) { 22 | const colorPosition = ((colors.length - 1) * value) / 256 23 | const colorIndex = Math.floor(colorPosition) 24 | const positionInColor = colorPosition - colorIndex 25 | const [r1, g1, b1, a1] = colors[colorIndex] 26 | const [r2, g2, b2, a2] = colors[colorIndex + 1] 27 | return [ 28 | Math.min(r1, r2) + Math.abs(r1 - r2) * positionInColor, 29 | Math.min(g1, g2) + Math.abs(g1 - g2) * positionInColor, 30 | Math.min(b1, b2) + Math.abs(b1 - b2) * positionInColor, 31 | Math.min(a1, a2) + Math.abs(a1 - a2) * positionInColor, 32 | ] 33 | } 34 | 35 | export default defineCustomElement('node-analyser', { 36 | template: html` 37 | 38 | Analyser 39 | Output 40 | open_in_newOpen 41 | Input 42 | 43 | `, 44 | shadow: false, 45 | setup({ host, connected, disconnected }) { 46 | const audioContext = useAudioContext() 47 | const analyser = audioContext.createAnalyser() 48 | analyser.smoothingTimeConstant = 0 49 | const frequencyData = new Uint8Array(analyser.frequencyBinCount) 50 | 51 | const analyserId = `analyser-${nextId('analyser')}` 52 | 53 | /** @type {Window} */ 54 | let analyserWindow = null 55 | 56 | /** @type {number} */ 57 | let analyseRequestId = null 58 | 59 | /** @type {HTMLCanvasElement} */ 60 | let canvas = null 61 | 62 | /** @type {CanvasRenderingContext2D} */ 63 | let canvasContext = null 64 | 65 | function analyse() { 66 | analyseRequestId = analyserWindow.requestAnimationFrame(analyse) 67 | 68 | analyser.getByteFrequencyData(frequencyData) 69 | const x = canvas.width - 1 70 | const previousImageData = canvasContext.getImageData(1, 0, x, canvas.height) 71 | canvasContext.putImageData(previousImageData, 0, 0) 72 | const newColumn = canvasContext.getImageData(x, 0, 1, canvas.height) 73 | for (let i = 0; i < frequencyData.length; i++) { 74 | const y = 4 * (frequencyData.length - i - 1) 75 | const [r, g, b, a] = getColor(frequencyData[i]) 76 | newColumn.data[y] = r 77 | newColumn.data[y + 1] = g 78 | newColumn.data[y + 2] = b 79 | newColumn.data[y + 3] = a 80 | } 81 | canvasContext.putImageData(newColumn, x, 0) 82 | } 83 | 84 | function closeAnalyserWindow() { 85 | if (analyserWindow) { 86 | analyserWindow.close() 87 | } 88 | } 89 | 90 | function handleAnalyserWindowUnload() { 91 | analyserWindow.cancelAnimationFrame(analyseRequestId) 92 | analyserWindow = null 93 | canvas = null 94 | canvasContext = null 95 | } 96 | 97 | function handleAnalyserWindowLoad() { 98 | const waaneApp = analyserWindow.document.body.querySelector('waane-app') 99 | const audioAnalyser = waaneApp.shadowRoot.querySelector('audio-analyser') 100 | canvas = audioAnalyser.shadowRoot.querySelector('canvas') 101 | canvas.width = period * 60 102 | canvas.height = analyser.frequencyBinCount 103 | canvasContext = canvas.getContext('2d') 104 | 105 | analyse() 106 | 107 | analyserWindow.addEventListener('unload', handleAnalyserWindowUnload) 108 | } 109 | 110 | connected(() => { 111 | /** @type {HTMLElement} */ 112 | const button = host.querySelector('w-button') 113 | 114 | bindAudioOutput(host.querySelector('w-graph-node-output'), analyser) 115 | bindAudioInput(host.querySelector('w-graph-node-input'), analyser) 116 | 117 | window.addEventListener('unload', closeAnalyserWindow) 118 | 119 | button.addEventListener('click', () => { 120 | if (analyserWindow === null) { 121 | analyserWindow = window.open('analyser', analyserId, 'width=800,height=600') 122 | analyserWindow.addEventListener('load', handleAnalyserWindowLoad) 123 | } else { 124 | analyserWindow.focus() 125 | } 126 | }) 127 | }) 128 | 129 | disconnected(() => { 130 | closeAnalyserWindow() 131 | window.removeEventListener('unload', closeAnalyserWindow) 132 | }) 133 | }, 134 | }) 135 | -------------------------------------------------------------------------------- /src/shared/node-editor/use-graph-node-selection.js: -------------------------------------------------------------------------------- 1 | import { css, defineCustomElement } from '../core/element.js' 2 | import { isField } from '../helpers/field.js' 3 | import { doOverlap } from '../helpers/geometry.js' 4 | 5 | /** 6 | * @typedef {import('./node-editor.js').default} NodeEditor 7 | * @typedef {import('./graph-node.js').default} GraphNode 8 | * @typedef {import('../helpers/geometry.js').Box} Box 9 | */ 10 | 11 | /** 12 | * @param {SelectionRectangle} selectionRectangle 13 | * @returns {Box} 14 | */ 15 | function getSelectionBox(selectionRectangle) { 16 | return { 17 | min: { 18 | x: Math.min(selectionRectangle.fromX, selectionRectangle.toX), 19 | y: Math.min(selectionRectangle.fromY, selectionRectangle.toY), 20 | }, 21 | max: { 22 | x: Math.max(selectionRectangle.fromX, selectionRectangle.toX), 23 | y: Math.max(selectionRectangle.fromY, selectionRectangle.toY), 24 | }, 25 | } 26 | } 27 | 28 | const SelectionRectangle = defineCustomElement('w-selection-rectangle', { 29 | styles: css` 30 | :host { 31 | position: absolute; 32 | z-index: 8; 33 | border: 1px solid rgba(var(--color-primary) / var(--text-medium-emphasis)); 34 | background-color: rgba(var(--color-primary) / 0.08); 35 | pointer-events: none; /* Required to keep click event firing */ 36 | } 37 | `, 38 | properties: { 39 | fromX: Number, 40 | fromY: Number, 41 | toX: Number, 42 | toY: Number, 43 | }, 44 | setup({ host, observe }) { 45 | observe('toX', () => { 46 | const selectionBox = getSelectionBox(host) 47 | host.style.left = `${selectionBox.min.x}px` 48 | host.style.width = `${selectionBox.max.x - selectionBox.min.x}px` 49 | }) 50 | 51 | observe('toY', () => { 52 | const selectionBox = getSelectionBox(host) 53 | host.style.top = `${selectionBox.min.y}px` 54 | host.style.height = `${selectionBox.max.y - selectionBox.min.y}px` 55 | }) 56 | }, 57 | }) 58 | 59 | /** 60 | * @param {NodeEditor} host 61 | */ 62 | export default function useGraphNodeSelection(host) { 63 | const selectionRectangle = /** @type {SelectionRectangle} */ (document.createElement('w-selection-rectangle')) 64 | 65 | function unselectAll() { 66 | host.querySelectorAll(`w-graph-node[selected]`).forEach((/** @type {GraphNode} */ selectedGraphNode) => { 67 | selectedGraphNode.selected = false 68 | }) 69 | } 70 | 71 | host.addEventListener('mousedown', (event) => { 72 | if (event.button !== 0 || event.altKey) { 73 | return 74 | } 75 | if (host.querySelector('w-graph-node[moving]')) { 76 | return 77 | } 78 | const element = /** @type {Element} */ (event.target) 79 | if (element.closest('w-graph-node')) { 80 | return 81 | } 82 | host.selecting = true 83 | selectionRectangle.fromX = event.offsetX 84 | selectionRectangle.fromY = event.offsetY 85 | }) 86 | 87 | host.addEventListener('mousemove', (event) => { 88 | if (!host.selecting) { 89 | return 90 | } 91 | if (!selectionRectangle.isConnected) { 92 | const root = host.shadowRoot || host 93 | root.appendChild(selectionRectangle) 94 | if (!event.ctrlKey && !event.metaKey) { 95 | unselectAll() 96 | } 97 | } 98 | selectionRectangle.toX = event.offsetX 99 | selectionRectangle.toY = event.offsetY 100 | 101 | const { x: hostX, y: hostY } = host.getBoundingClientRect() 102 | const selectionBox = getSelectionBox(selectionRectangle) 103 | host.querySelectorAll('w-graph-node').forEach((/** @type {GraphNode} */ graphNode) => { 104 | let { x, y, width, height } = graphNode.getBoundingClientRect() 105 | x -= hostX 106 | y -= hostY 107 | const graphNodeBox = { 108 | min: { x, y }, 109 | max: { x: x + width, y: y + height }, 110 | } 111 | graphNode.selecting = doOverlap(selectionBox, graphNodeBox) 112 | }) 113 | }) 114 | 115 | host.addEventListener('mouseup', () => { 116 | host.selecting = false 117 | if (!selectionRectangle.isConnected) { 118 | return 119 | } 120 | host.querySelectorAll(`w-graph-node[selecting]`).forEach((/** @type {GraphNode} */ graphNode) => { 121 | graphNode.selected = !graphNode.selected 122 | graphNode.selecting = false 123 | }) 124 | }) 125 | 126 | host.addEventListener('click', (event) => { 127 | if (isField(/** @type {HTMLElement} */ (event.target))) { 128 | return 129 | } 130 | if (selectionRectangle.isConnected) { 131 | selectionRectangle.remove() 132 | return 133 | } 134 | if (!event.ctrlKey && !event.metaKey) { 135 | unselectAll() 136 | } 137 | const element = /** @type {Element} */ (event.target) 138 | const graphNode = /** @type {GraphNode} */ (element.closest('w-graph-node')) 139 | if (graphNode) { 140 | graphNode.selected = !graphNode.selected 141 | } 142 | }) 143 | } 144 | -------------------------------------------------------------------------------- /src/shared/base/text-field.js: -------------------------------------------------------------------------------- 1 | import { css, defineCustomElement, html } from '../core/element.js' 2 | import typography from '../core/typography.js' 3 | 4 | const valueStyles = css` 5 | padding: 20px 16px 6px 16px; 6 | color: rgba(var(--color-on-surface) / var(--text-high-emphasis)); 7 | ${typography('body1')} 8 | ` 9 | 10 | export default defineCustomElement('w-text-field', { 11 | styles: css` 12 | :host { 13 | position: relative; 14 | min-width: 140px; 15 | height: 48px; 16 | margin-top: 8px; 17 | margin-bottom: 16px; 18 | display: flex; 19 | border-bottom: 1px solid rgba(var(--color-on-surface) / 0.42); 20 | border-radius: 4px 4px 0 0; 21 | background-color: rgba(var(--color-on-surface) / 0.04); 22 | } 23 | 24 | :host(:hover) { 25 | border-bottom-color: rgba(var(--color-on-surface) / var(--text-high-emphasis)); 26 | } 27 | 28 | input { 29 | ${valueStyles} 30 | flex: 1; 31 | outline: none; 32 | border: none; 33 | background: none; 34 | caret-color: rgb(var(--color-primary)); 35 | box-shadow: none; 36 | transition: caret-color 200ms var(--easing-standard); 37 | } 38 | 39 | input[type='number'] { 40 | appearance: textfield; 41 | } 42 | 43 | input[type='number']::-webkit-inner-spin-button { 44 | appearance: none; 45 | } 46 | 47 | input:invalid { 48 | caret-color: rgb(var(--color-error)); 49 | } 50 | 51 | label { 52 | position: absolute; 53 | top: 20px; 54 | left: 16px; 55 | transform: translateY(-100%) scale(0.75); 56 | transform-origin: bottom left; 57 | color: rgba(var(--color-on-surface) / var(--text-medium-emphasis)); 58 | pointer-events: none; 59 | transition: color 200ms var(--easing-standard), transform 200ms var(--easing-standard); 60 | ${typography('body1')} 61 | } 62 | 63 | :host(:not(:focus-within)) input:placeholder-shown + label { 64 | transform: translateY(-6px); 65 | } 66 | 67 | :host(:focus-within) input:not(:invalid) + label { 68 | color: rgba(var(--color-primary) / var(--text-high-emphasis)); 69 | } 70 | 71 | input:invalid + label { 72 | color: rgb(var(--color-error)); 73 | } 74 | 75 | span { 76 | ${valueStyles} 77 | position: absolute; 78 | top: 0; 79 | right: 0; 80 | bottom: -1px; 81 | left: 0; 82 | outline: none; 83 | border-bottom: 2px solid transparent; 84 | border-radius: 4px 4px 0 0; 85 | transition: background-color 200ms var(--easing-standard), border-bottom-color 200ms var(--easing-standard); 86 | } 87 | 88 | ::slotted(*) { 89 | pointer-events: none; 90 | } 91 | 92 | span:not([tabindex]) { 93 | pointer-events: none; 94 | } 95 | 96 | :host(:hover) span { 97 | background-color: rgba(var(--color-on-surface) / 0.04); 98 | } 99 | 100 | :host(:focus-within) span { 101 | background-color: rgba(var(--color-on-surface) / 0.08); 102 | } 103 | 104 | :host(:focus-within) input:not(:invalid) + label + span { 105 | border-bottom-color: rgb(var(--color-primary)); 106 | } 107 | 108 | input:invalid + label + span { 109 | border-bottom-color: rgb(var(--color-error)); 110 | } 111 | 112 | slot[name='trailing'] { 113 | position: absolute; 114 | right: 12px; 115 | height: 100%; 116 | display: flex; 117 | align-items: center; 118 | color: rgba(var(--color-on-surface) / var(--text-medium-emphasis)); 119 | pointer-events: none; 120 | transition: color 200ms var(--easing-standard); 121 | } 122 | 123 | :host(:focus-within) input:not(:invalid) + label + span + slot[name='trailing'] { 124 | color: rgb(var(--color-primary)); 125 | } 126 | 127 | input:invalid + label + span + slot[name='trailing'] { 128 | color: rgb(var(--color-error)); 129 | } 130 | `, 131 | template: html` 132 | 133 | 134 | 135 | 136 | `, 137 | properties: { 138 | label: String, 139 | value: String, 140 | type: String, 141 | step: String, 142 | }, 143 | setup({ host, observe }) { 144 | const label = host.shadowRoot.querySelector('label') 145 | const input = host.shadowRoot.querySelector('input') 146 | const span = host.shadowRoot.querySelector('span') 147 | 148 | observe('label', () => { 149 | label.textContent = host.label 150 | }) 151 | 152 | observe('value', () => { 153 | input.value = host.value 154 | }) 155 | 156 | observe('type', () => { 157 | if (host.type === 'select') { 158 | input.removeAttribute('type') 159 | input.hidden = true 160 | span.tabIndex = 0 161 | } else { 162 | input.type = host.type 163 | input.hidden = false 164 | span.removeAttribute('tabIndex') 165 | } 166 | }) 167 | 168 | observe('step', () => { 169 | input.step = host.step 170 | }) 171 | 172 | input.addEventListener('input', () => { 173 | host.value = input.value 174 | }) 175 | }, 176 | }) 177 | -------------------------------------------------------------------------------- /src/shared/node-editor/graph-link.js: -------------------------------------------------------------------------------- 1 | import { css, defineCustomElement, html } from '../core/element.js' 2 | 3 | /** 4 | * @typedef {import('./graph-node.js').default} GraphNode 5 | */ 6 | 7 | export default defineCustomElement('w-graph-link', { 8 | styles: css` 9 | :host { 10 | position: absolute; 11 | display: block; 12 | min-width: 20px; 13 | min-height: 20px; 14 | pointer-events: none; 15 | } 16 | 17 | svg { 18 | width: 100%; 19 | height: 100%; 20 | overflow: visible; 21 | fill: none; 22 | stroke-width: 2px; 23 | stroke: rgba(var(--color-on-background) / var(--text-disabled)); 24 | transition: stroke 150ms var(--easing-standard); 25 | } 26 | 27 | :host([linking]) { 28 | z-index: 3; 29 | } 30 | 31 | :host([linking]) svg { 32 | stroke: rgba(var(--color-on-background) / var(--text-high-emphasis)); 33 | } 34 | `, 35 | template: html` 36 | 37 | 38 | 39 | `, 40 | properties: { 41 | from: String, 42 | fromX: Number, 43 | fromY: Number, 44 | to: String, 45 | toX: Number, 46 | toY: Number, 47 | linking: Boolean, 48 | }, 49 | setup({ host, connected, disconnected, observe }) { 50 | const path = host.shadowRoot.querySelector('path') 51 | 52 | function getFromPosition() { 53 | const { from, fromX, fromY } = host 54 | if (!from) { 55 | return { fromX, fromY } 56 | } 57 | const root = /** @type {Document | ShadowRoot} */ (host.getRootNode()) 58 | 59 | /** @type {HTMLElement} */ 60 | const output = root.querySelector(`w-graph-node-output#${from}`) 61 | 62 | const graphNode = /** @type {GraphNode} */ (output.closest('w-graph-node')) 63 | return { 64 | fromX: graphNode.x + graphNode.offsetWidth, 65 | fromY: graphNode.y + output.offsetTop + output.offsetHeight / 2, 66 | } 67 | } 68 | 69 | function getToPosition() { 70 | const { to, toX, toY } = host 71 | if (!to) { 72 | return { toX, toY } 73 | } 74 | const root = /** @type {Document | ShadowRoot} */ (host.getRootNode()) 75 | 76 | /** @type {HTMLElement} */ 77 | const output = root.querySelector(`w-graph-node-input#${to}`) 78 | 79 | const graphNode = /** @type {GraphNode} */ (output.closest('w-graph-node')) 80 | return { 81 | toX: graphNode.x, 82 | toY: graphNode.y + output.offsetTop + output.offsetHeight / 2, 83 | } 84 | } 85 | 86 | function updatePath() { 87 | const { fromX, fromY } = getFromPosition() 88 | const { toX, toY } = getToPosition() 89 | 90 | const width = Math.abs(toX - fromX) 91 | const height = Math.abs(toY - fromY) 92 | 93 | host.style.left = `${Math.min(fromX, toX)}px` 94 | host.style.top = `${Math.min(fromY, toY)}px` 95 | host.style.width = `${width}px` 96 | host.style.height = `${height}px` 97 | 98 | const startPoint = { 99 | x: toX > fromX ? 0 : width, 100 | y: toY > fromY ? 0 : height, 101 | } 102 | const endPoint = { 103 | x: toX > fromX ? width : 0, 104 | y: toY > fromY ? height : 0, 105 | } 106 | const startControlPoint = { 107 | x: startPoint.x + width / 2, 108 | y: startPoint.y, 109 | } 110 | const endControlPoint = { 111 | x: endPoint.x - width / 2, 112 | y: endPoint.y, 113 | } 114 | path.setAttribute( 115 | 'd', 116 | [ 117 | `M ${startPoint.x},${startPoint.y}`, 118 | `C ${startControlPoint.x},${startControlPoint.y}`, 119 | `${endControlPoint.x},${endControlPoint.y}`, 120 | `${endPoint.x},${endPoint.y}`, 121 | ].join(' '), 122 | ) 123 | } 124 | 125 | const observer = new MutationObserver(updatePath) 126 | 127 | function updateObserver() { 128 | const { from, to } = host 129 | const root = /** @type {Document | ShadowRoot} */ (host.getRootNode()) 130 | 131 | /** @type {HTMLElement} */ 132 | const output = from && root.querySelector(`w-graph-node-output#${host.from}`) 133 | 134 | /** @type {HTMLElement} */ 135 | const input = to && root.querySelector(`w-graph-node-input#${host.to}`) 136 | 137 | observer.disconnect() 138 | 139 | observer.observe(host, { 140 | attributeFilter: ['from', 'from-x', 'from-y', 'to', 'to-x', 'to-y'], 141 | }) 142 | if (output) { 143 | observer.observe(output.closest('w-graph-node'), { 144 | attributeFilter: ['x', 'y'], 145 | }) 146 | } 147 | if (input) { 148 | observer.observe(input.closest('w-graph-node'), { 149 | attributeFilter: ['x', 'y'], 150 | }) 151 | } 152 | } 153 | 154 | connected(() => { 155 | updateObserver() 156 | 157 | // When creating a new link, ensures that the path is not rendered 158 | // before having from-x and from-y values 159 | if (host.from && host.to) { 160 | updatePath() 161 | } 162 | }) 163 | 164 | disconnected(() => { 165 | observer.disconnect() 166 | }) 167 | 168 | observe('from', updateObserver) 169 | 170 | observe('to', updateObserver) 171 | }, 172 | }) 173 | -------------------------------------------------------------------------------- /src/audio-node-editor/__tests__/graph-node.js: -------------------------------------------------------------------------------- 1 | import { expect, test } from '@jest/globals' 2 | import { click, contextMenu, setup } from '../../testing/helpers' 3 | 4 | test('selects nodes', () => { 5 | const { nodeEditor, addAudioNode, getGraphNodes } = setup('Nodes') 6 | addAudioNode('Oscillator') 7 | addAudioNode('Oscillator') 8 | const [graphNode1, graphNode2] = getGraphNodes() 9 | 10 | graphNode1.click() 11 | 12 | expect(graphNode1.selected).toBe(true) 13 | expect(graphNode2.selected).toBe(false) 14 | 15 | nodeEditor.click() 16 | 17 | expect(graphNode1.selected).toBe(false) 18 | expect(graphNode2.selected).toBe(false) 19 | }) 20 | 21 | test('inverts node selection', () => { 22 | const { addAudioNode, getGraphNodes } = setup('Nodes') 23 | addAudioNode('Oscillator') 24 | addAudioNode('Oscillator') 25 | const [graphNode1, graphNode2] = getGraphNodes() 26 | 27 | click(graphNode1) 28 | 29 | expect(graphNode1.selected).toBe(true) 30 | expect(graphNode2.selected).toBe(false) 31 | 32 | click(graphNode2, { ctrlKey: true }) 33 | 34 | expect(graphNode1.selected).toBe(true) 35 | expect(graphNode2.selected).toBe(true) 36 | 37 | click(graphNode1, { ctrlKey: true }) 38 | 39 | expect(graphNode1.selected).toBe(false) 40 | expect(graphNode2.selected).toBe(true) 41 | }) 42 | 43 | test('moves nodes', () => { 44 | const { addAudioNode, getGraphNodes, moveGraphNode } = setup('Nodes') 45 | addAudioNode('Oscillator') 46 | addAudioNode('Oscillator') 47 | addAudioNode('Oscillator') 48 | const [graphNode1, graphNode2, graphNode3] = getGraphNodes() 49 | graphNode1.x = 0 50 | graphNode1.y = 0 51 | graphNode2.x = 10 52 | graphNode2.y = 10 53 | graphNode3.x = 20 54 | graphNode3.y = 20 55 | 56 | click(graphNode1) 57 | click(graphNode2, { ctrlKey: true }) 58 | moveGraphNode(graphNode1, 5, 5) 59 | 60 | expect(graphNode1).toMatchObject({ x: 5, y: 5, selected: true }) 61 | expect(graphNode2).toMatchObject({ x: 15, y: 15, selected: true }) 62 | expect(graphNode3).toMatchObject({ x: 20, y: 20, selected: false }) 63 | 64 | moveGraphNode(graphNode3, 10, 10) 65 | 66 | expect(graphNode1).toMatchObject({ x: 5, y: 5, selected: false }) 67 | expect(graphNode2).toMatchObject({ x: 15, y: 15, selected: false }) 68 | expect(graphNode3).toMatchObject({ x: 30, y: 30, selected: true }) 69 | 70 | moveGraphNode(graphNode2, 20, 20, true) 71 | 72 | expect(graphNode1).toMatchObject({ x: 5, y: 5, selected: false }) 73 | expect(graphNode2).toMatchObject({ x: 35, y: 35, selected: true }) 74 | expect(graphNode3).toMatchObject({ x: 50, y: 50, selected: true }) 75 | }) 76 | 77 | test('opens context menu on selected nodes', () => { 78 | const { getMenuItems, addAudioNode, getGraphNodes } = setup('Nodes') 79 | addAudioNode('Oscillator') 80 | addAudioNode('Oscillator') 81 | addAudioNode('Oscillator') 82 | const [graphNode1, graphNode2, graphNode3] = getGraphNodes() 83 | 84 | const expectedMenuItems = ['Duplicate', 'Delete'].map((menuItem) => 85 | expect.objectContaining({ textContent: expect.stringContaining(menuItem) }), 86 | ) 87 | 88 | click(graphNode1) 89 | click(graphNode2, { ctrlKey: true }) 90 | contextMenu(graphNode1) 91 | 92 | expect(getMenuItems()).toEqual(expectedMenuItems) 93 | expect(graphNode1.selected).toBe(true) 94 | expect(graphNode2.selected).toBe(true) 95 | expect(graphNode3.selected).toBe(false) 96 | 97 | contextMenu(graphNode3) 98 | 99 | expect(getMenuItems()).toEqual(expectedMenuItems) 100 | expect(graphNode1.selected).toBe(false) 101 | expect(graphNode2.selected).toBe(false) 102 | expect(graphNode3.selected).toBe(true) 103 | 104 | contextMenu(graphNode2, { ctrlKey: true }) 105 | 106 | expect(getMenuItems()).toEqual(expectedMenuItems) 107 | expect(graphNode1.selected).toBe(false) 108 | expect(graphNode2.selected).toBe(true) 109 | expect(graphNode3.selected).toBe(true) 110 | 111 | document.body.dispatchEvent(new MouseEvent('mousedown')) 112 | 113 | expect(getMenuItems()).toHaveLength(0) 114 | }) 115 | 116 | test('duplicates nodes', () => { 117 | const { nodeEditor, getMenuItem, addAudioNode, getGraphNodes } = setup('Nodes') 118 | addAudioNode('Oscillator') 119 | addAudioNode('Oscillator') 120 | addAudioNode('Oscillator') 121 | const graphNodes = getGraphNodes() 122 | const [graphNode1, graphNode2] = graphNodes 123 | 124 | expect(graphNodes).toHaveLength(3) 125 | 126 | click(graphNode1) 127 | click(graphNode2, { ctrlKey: true }) 128 | contextMenu(graphNode1) 129 | getMenuItem('Duplicate').click() 130 | nodeEditor.dispatchEvent(new MouseEvent('mousemove')) 131 | click(nodeEditor) 132 | 133 | expect(getGraphNodes()).toEqual([ 134 | expect.objectContaining({ selected: false }), 135 | expect.objectContaining({ selected: false }), 136 | expect.objectContaining({ selected: false }), 137 | expect.objectContaining({ selected: true }), 138 | expect.objectContaining({ selected: true }), 139 | ]) 140 | }) 141 | 142 | test('deletes nodes', () => { 143 | const { getMenuItem, addAudioNode, getGraphNodes } = setup('Nodes') 144 | addAudioNode('Oscillator') 145 | addAudioNode('Oscillator') 146 | addAudioNode('Oscillator') 147 | const graphNodes = getGraphNodes() 148 | const [graphNode1, graphNode2, graphNode3] = graphNodes 149 | 150 | expect(graphNodes).toHaveLength(3) 151 | 152 | click(graphNode1) 153 | click(graphNode2, { ctrlKey: true }) 154 | contextMenu(graphNode1) 155 | getMenuItem('Delete').click() 156 | 157 | const remainingGraphNodes = getGraphNodes() 158 | expect(remainingGraphNodes).toHaveLength(1) 159 | expect(remainingGraphNodes[0]).toBe(graphNode3) 160 | }) 161 | -------------------------------------------------------------------------------- /src/helpers/import-file.js: -------------------------------------------------------------------------------- 1 | import { audioBuffers } from '../audio-node-editor/node-audio-file.js' 2 | import useAudioContext from '../audio-node-editor/use-audio-context.js' 3 | import { clearAll } from './file-helper.js' 4 | 5 | /** 6 | * @typedef {import('../audio-tracker/audio-tracker.js').default} AudioTracker 7 | * @typedef {import('../audio-tracker/audio-track.js').default} AudioTrack 8 | * @typedef {import('../audio-tracker/track-effect.js').default} TrackEffect 9 | * @typedef {import('../shared/node-editor/graph-node.js').default} GraphNode 10 | * @typedef {import('../shared/node-editor/graph-link.js').default} GraphLink 11 | * @typedef {import('./file-helper.js').FileContent} FileContent 12 | */ 13 | 14 | /** 15 | * @param {Object} attributes 16 | * @param {HTMLElement} element 17 | */ 18 | function importAttributes(attributes, element) { 19 | Object.entries(attributes).forEach(([attributeName, attributeValue]) => { 20 | element.setAttribute(attributeName, attributeValue) 21 | }) 22 | } 23 | 24 | /** 25 | * @param {FileContent} content 26 | * @param {AudioTracker} audioTracker 27 | */ 28 | function importTracker(content, audioTracker) { 29 | if (!content.tracker) { 30 | return 31 | } 32 | if (isFinite(content.tracker.tempo)) { 33 | audioTracker.tempo = content.tracker.tempo 34 | } 35 | if (isFinite(content.tracker.lines)) { 36 | audioTracker.lines = content.tracker.lines 37 | } 38 | if (isFinite(content.tracker.linesPerBeat)) { 39 | audioTracker.linesPerBeat = content.tracker.linesPerBeat 40 | } 41 | } 42 | 43 | /** 44 | * @param {FileContent} content 45 | * @param {AudioTracker} audioTracker 46 | */ 47 | function importTracks(content, audioTracker) { 48 | /** @type {Map} */ 49 | const trackLabels = new Map() 50 | 51 | content.tracks.forEach((track) => { 52 | const audioTrack = /** @type {AudioTrack} */ (document.createElement('audio-track')) 53 | for (let i = 0; i < audioTracker.lines; i++) { 54 | const trackEffect = /** @type {TrackEffect} */ (document.createElement('track-effect')) 55 | trackEffect.beat = i % audioTracker.linesPerBeat === 0 56 | if (i in track.effects) { 57 | trackEffect.value = track.effects[i] 58 | } 59 | audioTrack.appendChild(trackEffect) 60 | } 61 | audioTracker.shadowRoot.querySelector('.tracks').appendChild(audioTrack) 62 | trackLabels.set(track.label, audioTrack.label) 63 | }) 64 | return trackLabels 65 | } 66 | 67 | /** 68 | * @param {FileContent} content 69 | */ 70 | function importAudioFiles(content) { 71 | if (!content.audioFiles) { 72 | return 73 | } 74 | const audioContext = useAudioContext() 75 | content.audioFiles.forEach((audioFile) => { 76 | const audioBuffer = audioContext.createBuffer(audioFile.channels.length, audioFile.length, audioFile.sampleRate) 77 | audioFile.channels.forEach((encodedchannelData, channel) => { 78 | const channelData = new Uint8Array(audioBuffer.getChannelData(channel).buffer) 79 | const decodedChannelData = atob(encodedchannelData) 80 | for (let i = 0; i < decodedChannelData.length; i++) { 81 | channelData[i] = decodedChannelData.charCodeAt(i) 82 | } 83 | }) 84 | audioBuffers.set(audioFile.hash, audioBuffer) 85 | }) 86 | } 87 | 88 | /** 89 | * @param {FileContent} content 90 | * @param {Map} trackLabels 91 | * @param {HTMLElement} nodeEditor 92 | */ 93 | function importNodes(content, trackLabels, nodeEditor) { 94 | /** @type {Map} */ 95 | const nodeOutputs = new Map() 96 | 97 | /** @type {Map} */ 98 | const nodeInputs = new Map() 99 | 100 | content.nodes.forEach((node) => { 101 | if (node.attributes.track) { 102 | node.attributes.track = trackLabels.get(node.attributes.track) 103 | } 104 | const audioNode = document.createElement(node.name) 105 | nodeEditor.appendChild(audioNode) 106 | importAttributes(node.attributes, audioNode) 107 | const graphNode = /** @type {GraphNode} */ (audioNode.querySelector('w-graph-node')) 108 | graphNode.x = node.x 109 | graphNode.y = node.y 110 | audioNode.querySelectorAll('w-graph-node-output').forEach((output, index) => { 111 | nodeOutputs.set(node.outputs[index], output.id) 112 | }) 113 | audioNode.querySelectorAll('w-graph-node-input').forEach((input, index) => { 114 | nodeInputs.set(node.inputs[index], input.id) 115 | }) 116 | }) 117 | return { nodeOutputs, nodeInputs } 118 | } 119 | 120 | /** 121 | * @param {FileContent} content 122 | * @param {Map} outputs 123 | * @param {Map} inputs 124 | * @param {HTMLElement} nodeEditor 125 | */ 126 | function importLinks(content, outputs, inputs, nodeEditor) { 127 | content.links.forEach((link) => { 128 | const graphLink = /** @type {GraphLink} */ (document.createElement('w-graph-link')) 129 | graphLink.from = outputs.get(link.from) 130 | graphLink.to = inputs.get(link.to) 131 | nodeEditor.appendChild(graphLink) 132 | nodeEditor.dispatchEvent( 133 | new CustomEvent('graph-link-connect', { 134 | detail: { 135 | from: graphLink.from, 136 | to: graphLink.to, 137 | }, 138 | }), 139 | ) 140 | }) 141 | } 142 | 143 | /** 144 | * 145 | * @param {FileContent} content 146 | * @param {HTMLElement} audioNodeEditor 147 | * @param {AudioTracker} audioTracker 148 | */ 149 | export default function importFile(content, audioTracker, audioNodeEditor) { 150 | /** @type {HTMLElement} */ 151 | const nodeEditor = audioNodeEditor.shadowRoot.querySelector('w-node-editor') 152 | 153 | clearAll(audioTracker, audioNodeEditor) 154 | importTracker(content, audioTracker) 155 | const trackLabels = importTracks(content, audioTracker) 156 | importAttributes(content.nodeEditor, nodeEditor) 157 | importAudioFiles(content) 158 | const { nodeOutputs, nodeInputs } = importNodes(content, trackLabels, nodeEditor) 159 | let wasAudioNodeEditorHidden = false 160 | if (audioNodeEditor.hidden) { 161 | wasAudioNodeEditorHidden = true 162 | 163 | // Audio node editor must not be hidden for the graph links 164 | // to be positionned correctly 165 | audioNodeEditor.hidden = false 166 | } 167 | importLinks(content, nodeOutputs, nodeInputs, nodeEditor) 168 | if (wasAudioNodeEditorHidden) { 169 | audioNodeEditor.hidden = true 170 | } 171 | } 172 | -------------------------------------------------------------------------------- /src/audio-tracker/audio-tracker.js: -------------------------------------------------------------------------------- 1 | import { css, defineCustomElement, html } from '../shared/core/element.js' 2 | import useKeyboardNavigation from './use-keyboard-navigation.js' 3 | 4 | /** 5 | * @typedef {import('../shared/base/number-field.js').default} NumberField 6 | * @typedef {import('../shared/base/menu.js').default} Menu 7 | * @typedef {import('./audio-track.js').default} AudioTrack 8 | * @typedef {import('./track-effect.js').default} TrackEffect 9 | */ 10 | 11 | const tempoLabel = 'Tempo' 12 | const linesLabel = 'Lines' 13 | const linesPerBeatLabel = 'Lines per beat' 14 | 15 | export default defineCustomElement('audio-tracker', { 16 | styles: css` 17 | w-fab { 18 | position: absolute; 19 | left: 8px; 20 | opacity: 0; 21 | transform: translateY(-50%) scale(0.3); 22 | transition: opacity 75ms linear 75ms, transform 150ms var(--easing-accelerated); 23 | --color-fab: var(--color-secondary); 24 | --color-on-fab: var(--color-on-secondary); 25 | } 26 | 27 | :host([active]) w-fab { 28 | opacity: 1; 29 | transform: translateY(-50%); 30 | transition: opacity 75ms linear, transform 150ms var(--easing-decelerated); 31 | } 32 | 33 | .root { 34 | position: absolute; 35 | top: 0; 36 | right: 0; 37 | bottom: 0; 38 | left: 0; 39 | display: flex; 40 | background-color: rgb(var(--color-surface)); 41 | opacity: 0; 42 | transform: translateX(-100px); 43 | transition: opacity 150ms var(--easing-accelerated), transform 150ms var(--easing-accelerated); 44 | } 45 | 46 | :host([active]) .root { 47 | opacity: 1; 48 | transform: none; 49 | transition-timing-function: var(--easing-decelerated), var(--easing-decelerated); 50 | } 51 | 52 | aside { 53 | margin: 32px 16px 16px 16px; 54 | width: 160px; 55 | display: flex; 56 | flex-direction: column; 57 | } 58 | 59 | .tracks { 60 | flex: 1; 61 | display: flex; 62 | align-items: flex-start; 63 | overflow: auto; 64 | } 65 | 66 | .tracks > *:last-child::after { 67 | content: ''; 68 | position: absolute; 69 | right: -16px; 70 | bottom: -16px; 71 | width: 1px; 72 | height: 1px; 73 | } 74 | `, 75 | template: html` 76 | 77 | add 78 | Add track 79 | 80 |
81 | 86 |
87 |
88 | 89 | 90 | delete 91 | Delete track 92 | 93 | 94 | `, 95 | properties: { 96 | active: Boolean, 97 | tempo: Number, 98 | lines: Number, 99 | linesPerBeat: Number, 100 | }, 101 | setup({ host, observe }) { 102 | /** @type {HTMLElement} */ 103 | const fab = host.shadowRoot.querySelector('w-fab') 104 | 105 | /** @type {HTMLElement} */ 106 | const tracks = host.shadowRoot.querySelector('.tracks') 107 | 108 | /** @type {NumberField} */ 109 | const tempoField = host.shadowRoot.querySelector(`w-number-field[label='${tempoLabel}']`) 110 | 111 | /** @type {NumberField} */ 112 | const linesField = host.shadowRoot.querySelector(`w-number-field[label='${linesLabel}']`) 113 | 114 | /** @type {NumberField} */ 115 | const linesPerBeatField = host.shadowRoot.querySelector(`w-number-field[label='${linesPerBeatLabel}']`) 116 | 117 | /** @type {Menu} */ 118 | const menu = host.shadowRoot.querySelector('w-menu') 119 | 120 | /** @type {HTMLElement} */ 121 | const menuItemDelete = host.shadowRoot.querySelector('#delete') 122 | 123 | useKeyboardNavigation(tracks) 124 | 125 | /** @type {AudioTrack} */ 126 | let selectedAudioTrack = null 127 | 128 | observe('tempo', () => { 129 | tempoField.value = host.tempo 130 | }) 131 | 132 | observe('lines', () => { 133 | linesField.value = host.lines 134 | tracks.querySelectorAll('audio-track').forEach((audioTrack) => { 135 | const trackEffects = audioTrack.querySelectorAll('track-effect') 136 | if (trackEffects.length < host.lines) { 137 | for (let i = trackEffects.length; i < host.lines; i++) { 138 | const trackEffect = /** @type {TrackEffect} */ (document.createElement('track-effect')) 139 | trackEffect.beat = i % host.linesPerBeat === 0 140 | audioTrack.appendChild(trackEffect) 141 | } 142 | } else { 143 | trackEffects.forEach((trackEffect, i) => { 144 | if (i >= host.lines) { 145 | trackEffect.remove() 146 | } 147 | }) 148 | } 149 | }) 150 | }) 151 | 152 | observe('linesPerBeat', () => { 153 | linesPerBeatField.value = host.linesPerBeat 154 | tracks.querySelectorAll('audio-track').forEach((audioTrack) => { 155 | audioTrack.querySelectorAll('track-effect').forEach((/** @type {TrackEffect} */ trackEffect, i) => { 156 | trackEffect.beat = i % host.linesPerBeat === 0 157 | }) 158 | }) 159 | }) 160 | 161 | fab.addEventListener('click', () => { 162 | const audioTrack = /** @type {AudioTrack} */ (document.createElement('audio-track')) 163 | for (let i = 0; i < host.lines; i++) { 164 | const trackEffect = /** @type {TrackEffect} */ (document.createElement('track-effect')) 165 | trackEffect.beat = i % host.linesPerBeat === 0 166 | audioTrack.appendChild(trackEffect) 167 | } 168 | tracks.appendChild(audioTrack) 169 | }) 170 | 171 | tempoField.addEventListener('input', () => { 172 | host.tempo = tempoField.value 173 | }) 174 | 175 | linesField.addEventListener('input', () => { 176 | host.lines = linesField.value 177 | }) 178 | 179 | linesPerBeatField.addEventListener('input', () => { 180 | host.linesPerBeat = linesPerBeatField.value 181 | }) 182 | 183 | tracks.addEventListener('contextmenu', (event) => { 184 | const element = /** @type {Element} */ (event.target) 185 | selectedAudioTrack = element.closest('audio-track') 186 | if (!selectedAudioTrack) { 187 | return 188 | } 189 | menu.open = true 190 | menu.x = event.clientX 191 | menu.y = event.clientY 192 | }) 193 | 194 | menuItemDelete.addEventListener('click', () => { 195 | selectedAudioTrack.remove() 196 | selectedAudioTrack = null 197 | }) 198 | }, 199 | }) 200 | -------------------------------------------------------------------------------- /src/core/audio-editor.js: -------------------------------------------------------------------------------- 1 | import useAudioTracker from '../audio-tracker/use-audio-tracker.js' 2 | import exportFile from '../helpers/export-file.js' 3 | import { clearAll } from '../helpers/file-helper.js' 4 | import importFile from '../helpers/import-file.js' 5 | import { css, defineCustomElement, html } from '../shared/core/element.js' 6 | import elevation from '../shared/core/elevation.js' 7 | 8 | /** 9 | * @typedef {import('../shared/base/tab.js').default} Tab 10 | * @typedef {import('../shared/base/menu.js').default} Menu 11 | * @typedef {import('./button-play-pause.js').default} ButtonPlayPause 12 | * @typedef {import('../audio-tracker/audio-tracker.js').default} AudioTracker 13 | * @typedef {import('../audio-node-editor/audio-node-editor.js').default} AudioNodeEditor 14 | * @typedef {import('../helpers/import-file.js').FileContent} FileContent 15 | */ 16 | 17 | export default defineCustomElement('audio-editor', { 18 | styles: css` 19 | :host { 20 | height: 100%; 21 | display: flex; 22 | flex-direction: column; 23 | overflow: hidden; 24 | } 25 | 26 | header { 27 | position: relative; 28 | display: flex; 29 | justify-content: center; 30 | background-color: rgb(var(--color-surface)); 31 | ${elevation(4)} 32 | } 33 | 34 | .actions { 35 | position: absolute; 36 | top: 0; 37 | right: 4px; 38 | height: 100%; 39 | display: flex; 40 | align-items: center; 41 | } 42 | 43 | .actions > *:not(w-menu) { 44 | margin: 0 6px; 45 | } 46 | 47 | w-menu { 48 | position: absolute; 49 | top: 100%; 50 | right: 0; 51 | } 52 | 53 | main { 54 | position: relative; 55 | flex: 1; 56 | } 57 | `, 58 | template: html` 59 |
60 | Tracks 61 | Nodes 62 |
63 | 64 | 65 | more_vert 66 | 67 | 68 | 69 | create_new_folder 70 | New 71 | 72 | 73 | folder_open 74 | Open 75 | 76 |
77 | 78 | get_app 79 | Export 80 | 81 |
82 |
83 |
84 |
85 | 86 | 87 |
88 | `, 89 | setup({ host }) { 90 | const [tracksTab, nodesTab] = /** @type {NodeListOf} */ (host.shadowRoot.querySelectorAll('w-tab')) 91 | 92 | const buttonPlayPause = /** @type {ButtonPlayPause} */ (host.shadowRoot.querySelector('button-play-pause')) 93 | 94 | /** @type {HTMLElement} */ 95 | const buttonMore = host.shadowRoot.querySelector('w-icon-button') 96 | 97 | /** @type {Menu} */ 98 | const menu = host.shadowRoot.querySelector('w-menu') 99 | 100 | /** @type {HTMLElement} */ 101 | const menuItemNew = host.shadowRoot.querySelector('#new') 102 | 103 | /** @type {HTMLElement} */ 104 | const menuItemOpen = host.shadowRoot.querySelector('#open') 105 | 106 | /** @type {HTMLElement} */ 107 | const menuItemExport = host.shadowRoot.querySelector('#export') 108 | 109 | const audioTracker = /** @type {AudioTracker} */ (host.shadowRoot.querySelector('audio-tracker')) 110 | const audioNodeEditor = /** @type {AudioNodeEditor} */ (host.shadowRoot.querySelector('audio-node-editor')) 111 | 112 | /** @type {boolean} */ 113 | let isMenuOpenOnMouseDown 114 | 115 | const { startAudioTracker, stopAudioTracker, isAudioTrackerStarted } = useAudioTracker(audioTracker) 116 | 117 | function play() { 118 | startAudioTracker() 119 | buttonPlayPause.active = true 120 | } 121 | 122 | function pause() { 123 | stopAudioTracker() 124 | buttonPlayPause.active = false 125 | } 126 | 127 | host.addEventListener('contextmenu', (event) => { 128 | event.preventDefault() 129 | }) 130 | 131 | tracksTab.addEventListener('click', () => { 132 | nodesTab.active = false 133 | audioTracker.hidden = false 134 | audioNodeEditor.active = false 135 | setTimeout(() => { 136 | tracksTab.active = true 137 | audioTracker.active = true 138 | audioNodeEditor.hidden = true 139 | }, 150) 140 | }) 141 | 142 | nodesTab.addEventListener('click', () => { 143 | tracksTab.active = false 144 | audioTracker.active = false 145 | audioNodeEditor.hidden = false 146 | setTimeout(() => { 147 | nodesTab.active = true 148 | audioTracker.hidden = true 149 | audioNodeEditor.active = true 150 | }, 150) 151 | }) 152 | 153 | buttonPlayPause.addEventListener('click', () => { 154 | if (isAudioTrackerStarted()) { 155 | pause() 156 | } else { 157 | play() 158 | } 159 | }) 160 | 161 | buttonMore.addEventListener('mousedown', () => { 162 | isMenuOpenOnMouseDown = menu.open 163 | }) 164 | 165 | buttonMore.addEventListener('click', () => { 166 | if (!isMenuOpenOnMouseDown) { 167 | menu.open = true 168 | } 169 | }) 170 | 171 | menuItemNew.addEventListener('click', () => { 172 | pause() 173 | clearAll(audioTracker, audioNodeEditor) 174 | }) 175 | 176 | menuItemOpen.addEventListener('click', () => { 177 | const input = document.createElement('input') 178 | input.type = 'file' 179 | input.addEventListener('change', async () => { 180 | if (input.files.length !== 1) { 181 | return 182 | } 183 | pause() 184 | const file = input.files[0] 185 | const fileReader = new FileReader() 186 | fileReader.addEventListener('load', () => { 187 | /** @type {FileContent} */ 188 | const content = JSON.parse(/** @type {string} */ (fileReader.result)) 189 | 190 | importFile(content, audioTracker, audioNodeEditor) 191 | }) 192 | fileReader.readAsText(file) 193 | }) 194 | input.click() 195 | }) 196 | 197 | menuItemExport.addEventListener('click', () => { 198 | const content = exportFile(audioTracker, audioNodeEditor) 199 | 200 | const blob = new Blob([JSON.stringify(content, null, 2)], { 201 | type: 'application/json', 202 | }) 203 | const url = URL.createObjectURL(blob) 204 | const link = document.createElement('a') 205 | link.href = url 206 | link.download = 'waane-export.json' 207 | link.click() 208 | URL.revokeObjectURL(url) 209 | }) 210 | }, 211 | }) 212 | -------------------------------------------------------------------------------- /src/audio-node-editor/__tests__/graph-link.js: -------------------------------------------------------------------------------- 1 | import { expect, test } from '@jest/globals' 2 | import { click, contextMenu, setup } from '../../testing/helpers' 3 | 4 | test('adds a link from an output to an input', () => { 5 | const { addAudioNode, getGraphNodes, addGraphLink, getGraphLinks } = setup('Nodes') 6 | addAudioNode('Oscillator') 7 | addAudioNode('Audio destination') 8 | const [oscillator, audioDestination] = getGraphNodes() 9 | addGraphLink(oscillator, audioDestination) 10 | 11 | expect(getGraphLinks()).toHaveLength(1) 12 | }) 13 | 14 | test('adds a link from an input to an output', () => { 15 | const { nodeEditor, addAudioNode, getGraphNodes, getGraphLinks } = setup('Nodes') 16 | addAudioNode('Oscillator') 17 | addAudioNode('Audio destination') 18 | const [oscillator, audioDestination] = getGraphNodes() 19 | 20 | const graphNodeInput = audioDestination.querySelector('w-graph-node-input') 21 | const inputSocket = graphNodeInput.shadowRoot.querySelector('w-graph-node-socket') 22 | const graphNodeOutput = oscillator.querySelector('w-graph-node-output') 23 | const outputSocket = graphNodeOutput.shadowRoot.querySelector('w-graph-node-socket') 24 | inputSocket.dispatchEvent(new MouseEvent('mousedown')) 25 | outputSocket.dispatchEvent(new MouseEvent('mousemove', { bubbles: true, composed: true })) 26 | outputSocket.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, composed: true })) 27 | nodeEditor.click() 28 | 29 | expect(getGraphLinks()).toHaveLength(1) 30 | }) 31 | 32 | test('cancels adding a link when releasing on node editor', () => { 33 | const { nodeEditor, addAudioNode, getGraphLinks } = setup('Nodes') 34 | addAudioNode('Oscillator') 35 | 36 | const graphNodeOutput = nodeEditor.querySelector('w-graph-node-output') 37 | const outputSocket = graphNodeOutput.shadowRoot.querySelector('w-graph-node-socket') 38 | outputSocket.dispatchEvent(new MouseEvent('mousedown')) 39 | nodeEditor.dispatchEvent(new MouseEvent('mousemove')) 40 | 41 | expect(getGraphLinks()).toHaveLength(1) 42 | 43 | nodeEditor.dispatchEvent(new MouseEvent('mouseup')) 44 | nodeEditor.click() 45 | 46 | expect(getGraphLinks()).toHaveLength(0) 47 | }) 48 | 49 | test('cancels adding a link if sockets are already linked', () => { 50 | const { addAudioNode, getGraphNodes, addGraphLink, getGraphLinks } = setup('Nodes') 51 | addAudioNode('Oscillator') 52 | addAudioNode('Audio destination') 53 | const [oscillator, audioDestination] = getGraphNodes() 54 | addGraphLink(oscillator, audioDestination) 55 | 56 | expect(getGraphLinks()).toHaveLength(1) 57 | 58 | addGraphLink(oscillator, audioDestination) 59 | 60 | expect(getGraphLinks()).toHaveLength(1) 61 | }) 62 | 63 | test('deletes links when deleting output node', () => { 64 | const { getMenuItem, addAudioNode, getGraphNodes, addGraphLink, getGraphLinks } = setup('Nodes') 65 | addAudioNode('Oscillator') 66 | addAudioNode('Oscillator') 67 | addAudioNode('Audio destination') 68 | addAudioNode('Audio destination') 69 | const [oscillator1, oscillator2, audioDestination1, audioDestination2] = getGraphNodes() 70 | 71 | addGraphLink(oscillator1, audioDestination1) 72 | addGraphLink(oscillator2, audioDestination1) 73 | addGraphLink(oscillator2, audioDestination2) 74 | const [graphLink1] = getGraphLinks() 75 | 76 | click(oscillator2) 77 | contextMenu(oscillator2) 78 | getMenuItem('Delete').click() 79 | 80 | expect(getGraphNodes()).toEqual([oscillator1, audioDestination1, audioDestination2]) 81 | expect(getGraphLinks()).toEqual([graphLink1]) 82 | }) 83 | 84 | test('deletes links when deleting input node', () => { 85 | const { getMenuItem, addAudioNode, getGraphNodes, addGraphLink, getGraphLinks } = setup('Nodes') 86 | addAudioNode('Oscillator') 87 | addAudioNode('Oscillator') 88 | addAudioNode('Audio destination') 89 | addAudioNode('Audio destination') 90 | const [oscillator1, oscillator2, audioDestination1, audioDestination2] = getGraphNodes() 91 | 92 | addGraphLink(oscillator1, audioDestination1) 93 | addGraphLink(oscillator1, audioDestination2) 94 | addGraphLink(oscillator2, audioDestination2) 95 | const [graphLink1] = getGraphLinks() 96 | 97 | click(audioDestination2) 98 | contextMenu(audioDestination2) 99 | getMenuItem('Delete').click() 100 | 101 | expect(getGraphNodes()).toEqual([oscillator1, oscillator2, audioDestination1]) 102 | expect(getGraphLinks()).toEqual([graphLink1]) 103 | }) 104 | 105 | test('duplicates links when duplicating nodes', () => { 106 | const { getMenuItem, addAudioNode, getGraphNodes, addGraphLink, getGraphLinks } = setup('Nodes') 107 | addAudioNode('Oscillator') 108 | addAudioNode('Oscillator') 109 | addAudioNode('Audio destination') 110 | addAudioNode('Audio destination') 111 | const [oscillator1, oscillator2, audioDestination1, audioDestination2] = getGraphNodes() 112 | 113 | addGraphLink(oscillator1, audioDestination1) 114 | addGraphLink(oscillator2, audioDestination1) 115 | addGraphLink(oscillator2, audioDestination2) 116 | const [graphLink1, graphLink2, graphLink3] = getGraphLinks() 117 | 118 | click(oscillator2) 119 | click(audioDestination1, { ctrlKey: true }) 120 | contextMenu(oscillator2) 121 | getMenuItem('Duplicate').click() 122 | 123 | expect(getGraphNodes()).toEqual([ 124 | oscillator1, 125 | oscillator2, 126 | audioDestination1, 127 | audioDestination2, 128 | expect.objectContaining({ 129 | textContent: expect.stringContaining('Oscillator'), 130 | }), 131 | expect.objectContaining({ 132 | textContent: expect.stringContaining('Audio destination'), 133 | }), 134 | ]) 135 | expect(getGraphLinks()).toEqual([graphLink1, graphLink2, graphLink3, expect.anything()]) 136 | }) 137 | 138 | test('disconnects a link', () => { 139 | const { nodeEditor, addAudioNode, getGraphNodes, addGraphLink, getGraphLinks } = setup('Nodes') 140 | addAudioNode('Oscillator') 141 | addAudioNode('Audio destination') 142 | const [oscillator, audioDestination] = getGraphNodes() 143 | 144 | addGraphLink(oscillator, audioDestination) 145 | 146 | expect(getGraphLinks()).toHaveLength(1) 147 | 148 | const graphNodeInput = audioDestination.querySelector('w-graph-node-input') 149 | const inputSocket = graphNodeInput.shadowRoot.querySelector('w-graph-node-socket') 150 | inputSocket.dispatchEvent(new MouseEvent('mousedown')) 151 | nodeEditor.dispatchEvent(new MouseEvent('mousemove')) 152 | 153 | expect(getGraphLinks()).toHaveLength(1) 154 | 155 | nodeEditor.dispatchEvent(new MouseEvent('mouseup')) 156 | nodeEditor.click() 157 | 158 | expect(getGraphLinks()).toHaveLength(0) 159 | }) 160 | 161 | test('disconnects a specific link from a node', () => { 162 | const { nodeEditor, addAudioNode, getGraphNodes, addGraphLink, getGraphLinks } = setup('Nodes') 163 | addAudioNode('Oscillator') 164 | addAudioNode('Oscillator') 165 | addAudioNode('Audio destination') 166 | const [oscillator1, oscillator2, audioDestination] = getGraphNodes() 167 | 168 | addGraphLink(oscillator1, audioDestination) 169 | addGraphLink(oscillator2, audioDestination) 170 | 171 | expect(getGraphLinks()).toHaveLength(2) 172 | const [graphLink1] = getGraphLinks() 173 | 174 | const graphNodeInput = audioDestination.querySelector('w-graph-node-input') 175 | const inputSocket = graphNodeInput.shadowRoot.querySelector('w-graph-node-socket') 176 | 177 | inputSocket.dispatchEvent(new MouseEvent('mousedown')) 178 | inputSocket.dispatchEvent(new MouseEvent('mouseup')) 179 | nodeEditor.click() 180 | 181 | inputSocket.dispatchEvent(new MouseEvent('mousedown')) 182 | nodeEditor.dispatchEvent(new MouseEvent('mousemove')) 183 | nodeEditor.dispatchEvent(new MouseEvent('mouseup')) 184 | nodeEditor.click() 185 | 186 | expect(getGraphLinks()).toEqual([graphLink1]) 187 | }) 188 | -------------------------------------------------------------------------------- /src/testing/bass-drum-0.3.js: -------------------------------------------------------------------------------- 1 | export default { 2 | nodeEditor: { 3 | zoom: '0.6289999999999997', 4 | 'pan-x': '-449.7001009262679', 5 | 'pan-y': '-253.46960258430437', 6 | }, 7 | nodes: [ 8 | { 9 | name: 'node-track', 10 | x: -1003.1595382890781, 11 | y: 17.526804905206035, 12 | attributes: { 13 | track: '1', 14 | }, 15 | outputs: ['output-1'], 16 | inputs: [], 17 | }, 18 | { 19 | name: 'node-schedule', 20 | x: -729, 21 | y: -541, 22 | attributes: { 23 | 'start-time': '0.003', 24 | 'time-constant': '0.06', 25 | }, 26 | outputs: ['output-2'], 27 | inputs: ['input-1'], 28 | }, 29 | { 30 | name: 'node-schedule', 31 | x: -727, 32 | y: -90, 33 | attributes: { 34 | 'start-time': '0', 35 | 'time-constant': '0.001', 36 | 'target-value': '1', 37 | }, 38 | outputs: ['output-3'], 39 | inputs: ['input-2'], 40 | }, 41 | { 42 | name: 'node-constant', 43 | x: -372.60414325481185, 44 | y: -379.75106318595414, 45 | attributes: { 46 | offset: '0', 47 | }, 48 | outputs: ['output-4'], 49 | inputs: ['input-3'], 50 | }, 51 | { 52 | name: 'node-gain', 53 | x: 12.103986495540756, 54 | y: -216.77428701187904, 55 | attributes: { 56 | gain: '130', 57 | }, 58 | outputs: ['output-5'], 59 | inputs: ['input-4', 'input-5'], 60 | }, 61 | { 62 | name: 'node-oscillator', 63 | x: 356.65354866465316, 64 | y: -265.22661408843317, 65 | attributes: { 66 | type: 'sine', 67 | frequency: '0', 68 | detune: '0', 69 | }, 70 | outputs: ['output-6'], 71 | inputs: ['input-6', 'input-7'], 72 | }, 73 | { 74 | name: 'node-biquad-filter', 75 | x: 726.9356424947939, 76 | y: -371.6694446525491, 77 | attributes: { 78 | type: 'lowpass', 79 | frequency: '300', 80 | detune: '0', 81 | q: '20', 82 | gain: '0', 83 | }, 84 | outputs: ['output-7'], 85 | inputs: ['input-8', 'input-9', 'input-10', 'input-11', 'input-12'], 86 | }, 87 | { 88 | name: 'node-gain', 89 | x: 1095.6431254879901, 90 | y: -84.59461472057603, 91 | attributes: { 92 | gain: '1', 93 | }, 94 | outputs: ['output-8'], 95 | inputs: ['input-13', 'input-14'], 96 | }, 97 | { 98 | name: 'node-gain', 99 | x: 1595.5793906250203, 100 | y: -678.7856372098778, 101 | attributes: { 102 | gain: '0', 103 | }, 104 | outputs: ['output-9'], 105 | inputs: ['input-15', 'input-16'], 106 | }, 107 | { 108 | name: 'node-audio-destination', 109 | x: 1928.046450106558, 110 | y: -607.2777223804663, 111 | attributes: {}, 112 | outputs: [], 113 | inputs: ['input-17'], 114 | }, 115 | { 116 | name: 'node-schedule', 117 | x: -725.8203497615269, 118 | y: 368.37996820349827, 119 | attributes: { 120 | 'start-time': '0.003', 121 | 'time-constant': '0.001', 122 | }, 123 | outputs: ['output-10'], 124 | inputs: ['input-18'], 125 | }, 126 | { 127 | name: 'node-constant', 128 | x: -367.8346678971009, 129 | y: 78.11857115426857, 130 | attributes: { 131 | offset: '0', 132 | }, 133 | outputs: ['output-11'], 134 | inputs: ['input-19'], 135 | }, 136 | { 137 | name: 'node-oscillator', 138 | x: -725.6398685940358, 139 | y: 827.3642422564245, 140 | attributes: { 141 | type: 'triangle', 142 | frequency: '66', 143 | detune: '0', 144 | }, 145 | outputs: ['output-12'], 146 | inputs: ['input-20', 'input-21'], 147 | }, 148 | { 149 | name: 'node-gain', 150 | x: -363.9760363944888, 151 | y: 828.0161696940673, 152 | attributes: { 153 | gain: '500', 154 | }, 155 | outputs: ['output-13'], 156 | inputs: ['input-22', 'input-23'], 157 | }, 158 | { 159 | name: 'node-oscillator', 160 | x: 8.813079251769526, 161 | y: 785.8392989457668, 162 | attributes: { 163 | type: 'triangle', 164 | frequency: '40', 165 | detune: '0', 166 | }, 167 | outputs: ['output-14'], 168 | inputs: ['input-24', 'input-25'], 169 | }, 170 | { 171 | name: 'node-biquad-filter', 172 | x: 363.19496449168616, 173 | y: 561.4241560483201, 174 | attributes: { 175 | type: 'highpass', 176 | frequency: '40', 177 | detune: '0', 178 | q: '30', 179 | gain: '0', 180 | }, 181 | outputs: ['output-15'], 182 | inputs: ['input-26', 'input-27', 'input-28', 'input-29', 'input-30'], 183 | }, 184 | { 185 | name: 'node-biquad-filter', 186 | x: 728.4019181546178, 187 | y: 235.36867851460656, 188 | attributes: { 189 | type: 'lowpass', 190 | frequency: '130', 191 | detune: '0', 192 | q: '30', 193 | gain: '0', 194 | }, 195 | outputs: ['output-16'], 196 | inputs: ['input-31', 'input-32', 'input-33', 'input-34', 'input-35'], 197 | }, 198 | { 199 | name: 'node-gain', 200 | x: 365.4764205131359, 201 | y: 237.77116753357572, 202 | attributes: { 203 | gain: '5000', 204 | }, 205 | outputs: ['output-17'], 206 | inputs: ['input-36', 'input-37'], 207 | }, 208 | { 209 | name: 'node-gain', 210 | x: 1095.6431254879903, 211 | y: 240.9185817603625, 212 | attributes: { 213 | gain: '0.2', 214 | }, 215 | outputs: ['output-18'], 216 | inputs: ['input-38', 'input-39'], 217 | }, 218 | ], 219 | links: [ 220 | { 221 | from: 'output-1', 222 | to: 'input-1', 223 | }, 224 | { 225 | from: 'output-1', 226 | to: 'input-2', 227 | }, 228 | { 229 | from: 'output-2', 230 | to: 'input-3', 231 | }, 232 | { 233 | from: 'output-3', 234 | to: 'input-3', 235 | }, 236 | { 237 | from: 'output-4', 238 | to: 'input-5', 239 | }, 240 | { 241 | from: 'output-5', 242 | to: 'input-6', 243 | }, 244 | { 245 | from: 'output-6', 246 | to: 'input-12', 247 | }, 248 | { 249 | from: 'output-4', 250 | to: 'input-15', 251 | }, 252 | { 253 | from: 'output-8', 254 | to: 'input-16', 255 | }, 256 | { 257 | from: 'output-1', 258 | to: 'input-18', 259 | }, 260 | { 261 | from: 'output-3', 262 | to: 'input-19', 263 | }, 264 | { 265 | from: 'output-10', 266 | to: 'input-19', 267 | }, 268 | { 269 | from: 'output-12', 270 | to: 'input-23', 271 | }, 272 | { 273 | from: 'output-13', 274 | to: 'input-24', 275 | }, 276 | { 277 | from: 'output-14', 278 | to: 'input-30', 279 | }, 280 | { 281 | from: 'output-15', 282 | to: 'input-35', 283 | }, 284 | { 285 | from: 'output-11', 286 | to: 'input-37', 287 | }, 288 | { 289 | from: 'output-18', 290 | to: 'input-16', 291 | }, 292 | { 293 | from: 'output-16', 294 | to: 'input-39', 295 | }, 296 | { 297 | from: 'output-17', 298 | to: 'input-31', 299 | }, 300 | { 301 | from: 'output-7', 302 | to: 'input-14', 303 | }, 304 | { 305 | from: 'output-9', 306 | to: 'input-17', 307 | }, 308 | ], 309 | tracker: { 310 | tempo: 120, 311 | lines: 64, 312 | linesPerBeat: 4, 313 | }, 314 | tracks: [ 315 | { 316 | label: '1', 317 | effects: { 318 | 0: 'FF', 319 | 2: 'FF', 320 | 3: 'FF', 321 | 6: 'FF', 322 | 7: 'FF', 323 | 9: 'FF', 324 | 10: 'FF', 325 | 11: 'FF', 326 | 13: 'FF', 327 | 14: 'FF', 328 | }, 329 | }, 330 | ], 331 | audioFiles: [], 332 | } 333 | -------------------------------------------------------------------------------- /src/shared/core/element.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Alias for String.raw 3 | * Use it along with VS Code extension lit-html 4 | * to get template syntax highlighting. 5 | * 6 | * See https://marketplace.visualstudio.com/items?itemName=bierner.lit-html 7 | */ 8 | export const html = String.raw 9 | 10 | /** 11 | * Alias for String.raw 12 | * Use it along with VS Code extension vscode-styled-components 13 | * to get styles syntax highlighting. 14 | * 15 | * See https://marketplace.visualstudio.com/items?itemName=jpoissonnier.vscode-styled-components 16 | */ 17 | export const css = String.raw 18 | 19 | /** @typedef {typeof String | typeof Number | typeof Boolean} PropertyType */ 20 | 21 | /** 22 | * @template {PropertyType} T 23 | * @typedef {T extends typeof Number 24 | * ? number 25 | * : T extends typeof Boolean 26 | * ? boolean 27 | * : T extends typeof String 28 | * ? string 29 | * : never 30 | * } PrimitiveType 31 | */ 32 | 33 | /** 34 | * @template T 35 | * @typedef {object} AccessorProperty 36 | * @property {() => T} get 37 | * @property {(value: T) => void} set 38 | */ 39 | 40 | /** 41 | * @param {string} attributeName 42 | * @returns {AccessorProperty} 43 | */ 44 | function getStringProperty(attributeName) { 45 | return { 46 | /** @this {HTMLElement} */ 47 | get() { 48 | return this.getAttribute(attributeName) 49 | }, 50 | 51 | /** @this {HTMLElement} */ 52 | set(value) { 53 | if (value === null) { 54 | this.removeAttribute(attributeName) 55 | } else { 56 | this.setAttribute(attributeName, value) 57 | } 58 | }, 59 | } 60 | } 61 | 62 | /** 63 | * @param {string} attributeName 64 | * @returns {AccessorProperty} 65 | */ 66 | function getNumberProperty(attributeName) { 67 | return { 68 | /** @this {HTMLElement} */ 69 | get() { 70 | return Number(this.getAttribute(attributeName)) 71 | }, 72 | 73 | /** @this {HTMLElement} */ 74 | set(value) { 75 | if (value === null) { 76 | this.removeAttribute(attributeName) 77 | } else { 78 | this.setAttribute(attributeName, String(value)) 79 | } 80 | }, 81 | } 82 | } 83 | 84 | /** 85 | * @param {string} attributeName 86 | * @returns {AccessorProperty} 87 | */ 88 | function getBooleanProperty(attributeName) { 89 | return { 90 | /** @this {HTMLElement} */ 91 | get() { 92 | return this.hasAttribute(attributeName) 93 | }, 94 | 95 | /** @this {HTMLElement} */ 96 | set(value) { 97 | if (value) { 98 | this.setAttribute(attributeName, '') 99 | } else { 100 | this.removeAttribute(attributeName) 101 | } 102 | }, 103 | } 104 | } 105 | 106 | /** 107 | * @param {string} attributeName 108 | * @param {PropertyType} propertyType 109 | * @returns {AccessorProperty>} 110 | */ 111 | function getProperty(attributeName, propertyType) { 112 | switch (propertyType) { 113 | case Number: 114 | return getNumberProperty(attributeName) 115 | case Boolean: 116 | return getBooleanProperty(attributeName) 117 | default: 118 | return getStringProperty(attributeName) 119 | } 120 | } 121 | 122 | /** @typedef {Object} PropertyTypes */ 123 | 124 | /** 125 | * @template {PropertyTypes} T 126 | * @typedef {{[P in keyof T]: PrimitiveType}} Properties 127 | */ 128 | 129 | /** 130 | * @template {PropertyTypes} T 131 | * @callback Observe 132 | * @param {keyof T} propertyName 133 | * @param {() => void} callback 134 | * @returns {void} 135 | */ 136 | 137 | /** 138 | * @template {PropertyTypes} T 139 | * @typedef {object} SetupOptions 140 | * @property {HTMLElement & Properties} host 141 | * @property {(callback: () => void) => void} connected 142 | * @property {(callback: () => void) => void} disconnected 143 | * @property {Observe} observe 144 | */ 145 | 146 | /** 147 | * @template {PropertyTypes} T 148 | * @callback Setup 149 | * @param {SetupOptions} options 150 | * @returns {void} 151 | */ 152 | 153 | /** 154 | * @template {PropertyTypes} T 155 | * @typedef {object} DefineCustomElementOptions 156 | * @property {string} [styles] 157 | * @property {string} [template] 158 | * @property {boolean} [shadow] 159 | * @property {T} [properties] 160 | * @property {Setup} [setup] 161 | */ 162 | 163 | /** 164 | * @template {PropertyTypes} T 165 | * @param {string} name 166 | * @param {DefineCustomElementOptions} options 167 | * @returns {HTMLElement & Properties} 168 | */ 169 | export function defineCustomElement( 170 | name, 171 | { styles, template = html``, shadow = true, properties = /** @type {T} */ ({}), setup = () => {} }, 172 | ) { 173 | const templateElement = document.createElement('template') 174 | templateElement.innerHTML = styles 175 | ? html` 176 | 179 | ${template} 180 | ` 181 | : template 182 | 183 | /** @type {Object} */ 184 | const attributesByProperty = Object.keys(properties).reduce( 185 | (result, propertyName) => 186 | Object.assign(result, { 187 | [propertyName]: ( 188 | propertyName.substring(0, 1) + propertyName.substring(1).replace(/[A-Z]/g, '-$&') 189 | ).toLowerCase(), 190 | }), 191 | {}, 192 | ) 193 | 194 | const reflectedProperties = Object.entries(properties).reduce( 195 | (result, [propertyName, propertyType]) => 196 | Object.assign(result, { 197 | [propertyName]: getProperty(attributesByProperty[propertyName], propertyType), 198 | }), 199 | {}, 200 | ) 201 | 202 | class CustomElement extends HTMLElement { 203 | _connectedCallback = () => {} 204 | _disconnectedCallback = () => {} 205 | 206 | /** @type {Object void>} */ 207 | _attributeChangedCallbacks = Object.values(attributesByProperty).reduce( 208 | (callbacks, attributeName) => Object.assign(callbacks, { [attributeName]: () => {} }), 209 | {}, 210 | ) 211 | 212 | /** @this {CustomElement & Properties} */ 213 | constructor() { 214 | super() 215 | 216 | if (shadow) { 217 | this.attachShadow({ mode: 'open' }) 218 | this.shadowRoot.appendChild(templateElement.content.cloneNode(true)) 219 | } 220 | 221 | const propertiesBackup = Object.keys(reflectedProperties).reduce( 222 | (result, propertyName) => 223 | this[propertyName] === undefined ? result : Object.assign(result, { [propertyName]: this[propertyName] }), 224 | /** @type {Properties} */ ({}), 225 | ) 226 | 227 | Object.defineProperties(this, reflectedProperties) 228 | 229 | setup({ 230 | host: this, 231 | connected: (callback) => { 232 | this._connectedCallback = callback 233 | }, 234 | disconnected: (callback) => { 235 | this._disconnectedCallback = callback 236 | }, 237 | observe: /** @type {Observe} */ ((/** @type {string} */ propertyName, callback) => { 238 | const attributeName = attributesByProperty[propertyName] 239 | this._attributeChangedCallbacks[attributeName] = callback 240 | }), 241 | }) 242 | 243 | Object.entries(propertiesBackup).forEach(([propertyName, value]) => { 244 | this[/** @type {keyof T} */ (propertyName)] = value 245 | const attributeName = attributesByProperty[propertyName] 246 | this._attributeChangedCallbacks[attributeName]() 247 | }) 248 | } 249 | 250 | static get observedAttributes() { 251 | return Object.values(attributesByProperty) 252 | } 253 | 254 | connectedCallback() { 255 | if (!shadow) { 256 | this.appendChild(templateElement.content.cloneNode(true)) 257 | } 258 | this._connectedCallback() 259 | } 260 | 261 | disconnectedCallback() { 262 | this._disconnectedCallback() 263 | } 264 | 265 | /** 266 | * @template {PropertyType} T 267 | * @param {string} name 268 | * @param {PrimitiveType} oldValue 269 | * @param {PrimitiveType} newValue 270 | */ 271 | attributeChangedCallback(name, oldValue, newValue) { 272 | if (newValue !== oldValue) { 273 | this._attributeChangedCallbacks[name]() 274 | } 275 | } 276 | } 277 | 278 | customElements.define(name, CustomElement) 279 | 280 | return /** @type {HTMLElement & Properties} */ (/** @type {unknown} */ (CustomElement)) 281 | } 282 | -------------------------------------------------------------------------------- /src/testing/bass-drum-0.1.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @typedef {import("../helpers/file-helper.js").FileContent} FileContent 3 | */ 4 | 5 | export default /** @type {FileContent} */ ( 6 | /** @type {unknown} */ ({ 7 | nodeEditor: { 8 | zoom: '0.6289999999999997', 9 | 'pan-x': '-449.7001009262679', 10 | 'pan-y': '-253.46960258430437', 11 | }, 12 | nodes: [ 13 | { 14 | name: 'node-track', 15 | x: -1003.1595382890781, 16 | y: 17.526804905206035, 17 | attributes: { 18 | track: '1', 19 | }, 20 | outputs: ['output-1'], 21 | inputs: [], 22 | }, 23 | { 24 | name: 'node-schedule', 25 | x: -729, 26 | y: -541, 27 | attributes: { 28 | 'start-time': '0.003', 29 | 'time-constant': '0.06', 30 | }, 31 | outputs: ['output-2'], 32 | inputs: ['input-1'], 33 | }, 34 | { 35 | name: 'node-schedule', 36 | x: -727, 37 | y: -90, 38 | attributes: { 39 | 'start-time': '0', 40 | 'time-constant': '0.001', 41 | 'target-value': '1', 42 | }, 43 | outputs: ['output-3'], 44 | inputs: ['input-2'], 45 | }, 46 | { 47 | name: 'node-constant', 48 | x: -372.60414325481185, 49 | y: -379.75106318595414, 50 | attributes: { 51 | offset: '0', 52 | }, 53 | outputs: ['output-4'], 54 | inputs: ['input-3'], 55 | }, 56 | { 57 | name: 'node-gain', 58 | x: 12.103986495540756, 59 | y: -216.77428701187904, 60 | attributes: { 61 | gain: '130', 62 | }, 63 | outputs: ['output-5'], 64 | inputs: ['input-4', 'input-5'], 65 | }, 66 | { 67 | name: 'node-oscillator', 68 | x: 356.65354866465316, 69 | y: -265.22661408843317, 70 | attributes: { 71 | type: 'sine', 72 | frequency: '0', 73 | detune: '0', 74 | }, 75 | outputs: ['output-6'], 76 | inputs: ['input-6', 'input-7'], 77 | }, 78 | { 79 | name: 'node-biquad-filter', 80 | x: 726.9356424947939, 81 | y: -371.6694446525491, 82 | attributes: { 83 | type: 'lowpass', 84 | frequency: '300', 85 | detune: '0', 86 | q: '20', 87 | gain: '0', 88 | }, 89 | outputs: ['output-7'], 90 | inputs: ['input-8', 'input-9', 'input-10', 'input-11', 'input-12'], 91 | }, 92 | { 93 | name: 'node-gain', 94 | x: 1095.6431254879901, 95 | y: -84.59461472057603, 96 | attributes: { 97 | gain: '1', 98 | }, 99 | outputs: ['output-8'], 100 | inputs: ['input-13', 'input-14'], 101 | }, 102 | { 103 | name: 'node-gain', 104 | x: 1595.5793906250203, 105 | y: -678.7856372098778, 106 | attributes: { 107 | gain: '0', 108 | }, 109 | outputs: ['output-9'], 110 | inputs: ['input-15', 'input-16'], 111 | }, 112 | { 113 | name: 'node-audio-destination', 114 | x: 1928.046450106558, 115 | y: -607.2777223804663, 116 | attributes: {}, 117 | outputs: [], 118 | inputs: ['input-17'], 119 | }, 120 | { 121 | name: 'node-schedule', 122 | x: -725.8203497615269, 123 | y: 368.37996820349827, 124 | attributes: { 125 | 'start-time': '0.003', 126 | 'time-constant': '0.001', 127 | }, 128 | outputs: ['output-10'], 129 | inputs: ['input-18'], 130 | }, 131 | { 132 | name: 'node-constant', 133 | x: -367.8346678971009, 134 | y: 78.11857115426857, 135 | attributes: { 136 | offset: '0', 137 | }, 138 | outputs: ['output-11'], 139 | inputs: ['input-19'], 140 | }, 141 | { 142 | name: 'node-oscillator', 143 | x: -725.6398685940358, 144 | y: 827.3642422564245, 145 | attributes: { 146 | type: 'triangle', 147 | frequency: '66', 148 | detune: '0', 149 | }, 150 | outputs: ['output-12'], 151 | inputs: ['input-20', 'input-21'], 152 | }, 153 | { 154 | name: 'node-gain', 155 | x: -363.9760363944888, 156 | y: 828.0161696940673, 157 | attributes: { 158 | gain: '500', 159 | }, 160 | outputs: ['output-13'], 161 | inputs: ['input-22', 'input-23'], 162 | }, 163 | { 164 | name: 'node-oscillator', 165 | x: 8.813079251769526, 166 | y: 785.8392989457668, 167 | attributes: { 168 | type: 'triangle', 169 | frequency: '40', 170 | detune: '0', 171 | }, 172 | outputs: ['output-14'], 173 | inputs: ['input-24', 'input-25'], 174 | }, 175 | { 176 | name: 'node-biquad-filter', 177 | x: 363.19496449168616, 178 | y: 561.4241560483201, 179 | attributes: { 180 | type: 'highpass', 181 | frequency: '40', 182 | detune: '0', 183 | q: '30', 184 | gain: '0', 185 | }, 186 | outputs: ['output-15'], 187 | inputs: ['input-26', 'input-27', 'input-28', 'input-29', 'input-30'], 188 | }, 189 | { 190 | name: 'node-biquad-filter', 191 | x: 728.4019181546178, 192 | y: 235.36867851460656, 193 | attributes: { 194 | type: 'lowpass', 195 | frequency: '130', 196 | detune: '0', 197 | q: '30', 198 | gain: '0', 199 | }, 200 | outputs: ['output-16'], 201 | inputs: ['input-31', 'input-32', 'input-33', 'input-34', 'input-35'], 202 | }, 203 | { 204 | name: 'node-gain', 205 | x: 365.4764205131359, 206 | y: 237.77116753357572, 207 | attributes: { 208 | gain: '5000', 209 | }, 210 | outputs: ['output-17'], 211 | inputs: ['input-36', 'input-37'], 212 | }, 213 | { 214 | name: 'node-gain', 215 | x: 1095.6431254879903, 216 | y: 240.9185817603625, 217 | attributes: { 218 | gain: '0.2', 219 | }, 220 | outputs: ['output-18'], 221 | inputs: ['input-38', 'input-39'], 222 | }, 223 | ], 224 | links: [ 225 | { 226 | from: 'output-1', 227 | to: 'input-1', 228 | }, 229 | { 230 | from: 'output-1', 231 | to: 'input-2', 232 | }, 233 | { 234 | from: 'output-2', 235 | to: 'input-3', 236 | }, 237 | { 238 | from: 'output-3', 239 | to: 'input-3', 240 | }, 241 | { 242 | from: 'output-4', 243 | to: 'input-5', 244 | }, 245 | { 246 | from: 'output-5', 247 | to: 'input-6', 248 | }, 249 | { 250 | from: 'output-6', 251 | to: 'input-12', 252 | }, 253 | { 254 | from: 'output-4', 255 | to: 'input-15', 256 | }, 257 | { 258 | from: 'output-8', 259 | to: 'input-16', 260 | }, 261 | { 262 | from: 'output-1', 263 | to: 'input-18', 264 | }, 265 | { 266 | from: 'output-3', 267 | to: 'input-19', 268 | }, 269 | { 270 | from: 'output-10', 271 | to: 'input-19', 272 | }, 273 | { 274 | from: 'output-12', 275 | to: 'input-23', 276 | }, 277 | { 278 | from: 'output-13', 279 | to: 'input-24', 280 | }, 281 | { 282 | from: 'output-14', 283 | to: 'input-30', 284 | }, 285 | { 286 | from: 'output-15', 287 | to: 'input-35', 288 | }, 289 | { 290 | from: 'output-11', 291 | to: 'input-37', 292 | }, 293 | { 294 | from: 'output-18', 295 | to: 'input-16', 296 | }, 297 | { 298 | from: 'output-16', 299 | to: 'input-39', 300 | }, 301 | { 302 | from: 'output-17', 303 | to: 'input-31', 304 | }, 305 | { 306 | from: 'output-7', 307 | to: 'input-14', 308 | }, 309 | { 310 | from: 'output-9', 311 | to: 'input-17', 312 | }, 313 | ], 314 | tracks: [ 315 | { 316 | label: '1', 317 | effects: { 318 | 0: 'FF', 319 | 2: 'FF', 320 | 3: 'FF', 321 | 6: 'FF', 322 | 7: 'FF', 323 | 9: 'FF', 324 | 10: 'FF', 325 | 11: 'FF', 326 | 13: 'FF', 327 | 14: 'FF', 328 | }, 329 | }, 330 | ], 331 | }) 332 | ) 333 | -------------------------------------------------------------------------------- /src/testing/bass-drum-0.2.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @typedef {import("../helpers/file-helper.js").FileContent} FileContent 3 | */ 4 | 5 | export default /** @type {FileContent} */ ( 6 | /** @type {unknown} */ ({ 7 | nodeEditor: { 8 | zoom: '0.6289999999999997', 9 | 'pan-x': '-449.7001009262679', 10 | 'pan-y': '-253.46960258430437', 11 | }, 12 | nodes: [ 13 | { 14 | name: 'node-track', 15 | x: -1003.1595382890781, 16 | y: 17.526804905206035, 17 | attributes: { 18 | track: '1', 19 | }, 20 | outputs: ['output-1'], 21 | inputs: [], 22 | }, 23 | { 24 | name: 'node-schedule', 25 | x: -729, 26 | y: -541, 27 | attributes: { 28 | 'start-time': '0.003', 29 | 'time-constant': '0.06', 30 | }, 31 | outputs: ['output-2'], 32 | inputs: ['input-1'], 33 | }, 34 | { 35 | name: 'node-schedule', 36 | x: -727, 37 | y: -90, 38 | attributes: { 39 | 'start-time': '0', 40 | 'time-constant': '0.001', 41 | 'target-value': '1', 42 | }, 43 | outputs: ['output-3'], 44 | inputs: ['input-2'], 45 | }, 46 | { 47 | name: 'node-constant', 48 | x: -372.60414325481185, 49 | y: -379.75106318595414, 50 | attributes: { 51 | offset: '0', 52 | }, 53 | outputs: ['output-4'], 54 | inputs: ['input-3'], 55 | }, 56 | { 57 | name: 'node-gain', 58 | x: 12.103986495540756, 59 | y: -216.77428701187904, 60 | attributes: { 61 | gain: '130', 62 | }, 63 | outputs: ['output-5'], 64 | inputs: ['input-4', 'input-5'], 65 | }, 66 | { 67 | name: 'node-oscillator', 68 | x: 356.65354866465316, 69 | y: -265.22661408843317, 70 | attributes: { 71 | type: 'sine', 72 | frequency: '0', 73 | detune: '0', 74 | }, 75 | outputs: ['output-6'], 76 | inputs: ['input-6', 'input-7'], 77 | }, 78 | { 79 | name: 'node-biquad-filter', 80 | x: 726.9356424947939, 81 | y: -371.6694446525491, 82 | attributes: { 83 | type: 'lowpass', 84 | frequency: '300', 85 | detune: '0', 86 | q: '20', 87 | gain: '0', 88 | }, 89 | outputs: ['output-7'], 90 | inputs: ['input-8', 'input-9', 'input-10', 'input-11', 'input-12'], 91 | }, 92 | { 93 | name: 'node-gain', 94 | x: 1095.6431254879901, 95 | y: -84.59461472057603, 96 | attributes: { 97 | gain: '1', 98 | }, 99 | outputs: ['output-8'], 100 | inputs: ['input-13', 'input-14'], 101 | }, 102 | { 103 | name: 'node-gain', 104 | x: 1595.5793906250203, 105 | y: -678.7856372098778, 106 | attributes: { 107 | gain: '0', 108 | }, 109 | outputs: ['output-9'], 110 | inputs: ['input-15', 'input-16'], 111 | }, 112 | { 113 | name: 'node-audio-destination', 114 | x: 1928.046450106558, 115 | y: -607.2777223804663, 116 | attributes: {}, 117 | outputs: [], 118 | inputs: ['input-17'], 119 | }, 120 | { 121 | name: 'node-schedule', 122 | x: -725.8203497615269, 123 | y: 368.37996820349827, 124 | attributes: { 125 | 'start-time': '0.003', 126 | 'time-constant': '0.001', 127 | }, 128 | outputs: ['output-10'], 129 | inputs: ['input-18'], 130 | }, 131 | { 132 | name: 'node-constant', 133 | x: -367.8346678971009, 134 | y: 78.11857115426857, 135 | attributes: { 136 | offset: '0', 137 | }, 138 | outputs: ['output-11'], 139 | inputs: ['input-19'], 140 | }, 141 | { 142 | name: 'node-oscillator', 143 | x: -725.6398685940358, 144 | y: 827.3642422564245, 145 | attributes: { 146 | type: 'triangle', 147 | frequency: '66', 148 | detune: '0', 149 | }, 150 | outputs: ['output-12'], 151 | inputs: ['input-20', 'input-21'], 152 | }, 153 | { 154 | name: 'node-gain', 155 | x: -363.9760363944888, 156 | y: 828.0161696940673, 157 | attributes: { 158 | gain: '500', 159 | }, 160 | outputs: ['output-13'], 161 | inputs: ['input-22', 'input-23'], 162 | }, 163 | { 164 | name: 'node-oscillator', 165 | x: 8.813079251769526, 166 | y: 785.8392989457668, 167 | attributes: { 168 | type: 'triangle', 169 | frequency: '40', 170 | detune: '0', 171 | }, 172 | outputs: ['output-14'], 173 | inputs: ['input-24', 'input-25'], 174 | }, 175 | { 176 | name: 'node-biquad-filter', 177 | x: 363.19496449168616, 178 | y: 561.4241560483201, 179 | attributes: { 180 | type: 'highpass', 181 | frequency: '40', 182 | detune: '0', 183 | q: '30', 184 | gain: '0', 185 | }, 186 | outputs: ['output-15'], 187 | inputs: ['input-26', 'input-27', 'input-28', 'input-29', 'input-30'], 188 | }, 189 | { 190 | name: 'node-biquad-filter', 191 | x: 728.4019181546178, 192 | y: 235.36867851460656, 193 | attributes: { 194 | type: 'lowpass', 195 | frequency: '130', 196 | detune: '0', 197 | q: '30', 198 | gain: '0', 199 | }, 200 | outputs: ['output-16'], 201 | inputs: ['input-31', 'input-32', 'input-33', 'input-34', 'input-35'], 202 | }, 203 | { 204 | name: 'node-gain', 205 | x: 365.4764205131359, 206 | y: 237.77116753357572, 207 | attributes: { 208 | gain: '5000', 209 | }, 210 | outputs: ['output-17'], 211 | inputs: ['input-36', 'input-37'], 212 | }, 213 | { 214 | name: 'node-gain', 215 | x: 1095.6431254879903, 216 | y: 240.9185817603625, 217 | attributes: { 218 | gain: '0.2', 219 | }, 220 | outputs: ['output-18'], 221 | inputs: ['input-38', 'input-39'], 222 | }, 223 | ], 224 | links: [ 225 | { 226 | from: 'output-1', 227 | to: 'input-1', 228 | }, 229 | { 230 | from: 'output-1', 231 | to: 'input-2', 232 | }, 233 | { 234 | from: 'output-2', 235 | to: 'input-3', 236 | }, 237 | { 238 | from: 'output-3', 239 | to: 'input-3', 240 | }, 241 | { 242 | from: 'output-4', 243 | to: 'input-5', 244 | }, 245 | { 246 | from: 'output-5', 247 | to: 'input-6', 248 | }, 249 | { 250 | from: 'output-6', 251 | to: 'input-12', 252 | }, 253 | { 254 | from: 'output-4', 255 | to: 'input-15', 256 | }, 257 | { 258 | from: 'output-8', 259 | to: 'input-16', 260 | }, 261 | { 262 | from: 'output-1', 263 | to: 'input-18', 264 | }, 265 | { 266 | from: 'output-3', 267 | to: 'input-19', 268 | }, 269 | { 270 | from: 'output-10', 271 | to: 'input-19', 272 | }, 273 | { 274 | from: 'output-12', 275 | to: 'input-23', 276 | }, 277 | { 278 | from: 'output-13', 279 | to: 'input-24', 280 | }, 281 | { 282 | from: 'output-14', 283 | to: 'input-30', 284 | }, 285 | { 286 | from: 'output-15', 287 | to: 'input-35', 288 | }, 289 | { 290 | from: 'output-11', 291 | to: 'input-37', 292 | }, 293 | { 294 | from: 'output-18', 295 | to: 'input-16', 296 | }, 297 | { 298 | from: 'output-16', 299 | to: 'input-39', 300 | }, 301 | { 302 | from: 'output-17', 303 | to: 'input-31', 304 | }, 305 | { 306 | from: 'output-7', 307 | to: 'input-14', 308 | }, 309 | { 310 | from: 'output-9', 311 | to: 'input-17', 312 | }, 313 | ], 314 | tracker: { 315 | tempo: 120, 316 | lines: 64, 317 | linesPerBeat: 4, 318 | }, 319 | tracks: [ 320 | { 321 | label: '1', 322 | effects: { 323 | 0: 'FF', 324 | 2: 'FF', 325 | 3: 'FF', 326 | 6: 'FF', 327 | 7: 'FF', 328 | 9: 'FF', 329 | 10: 'FF', 330 | 11: 'FF', 331 | 13: 'FF', 332 | 14: 'FF', 333 | }, 334 | }, 335 | ], 336 | }) 337 | ) 338 | --------------------------------------------------------------------------------