diff --git a/package-lock.json b/package-lock.json index 706b874..d83d89f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@react-hook/resize-observer": "^2.0.2", "@tonaljs/note": "^4.11.0", + "@tonaljs/scale": "^4.13.0", "classnames": "^2.5.1", "immer": "^10.1.1", "react": "^18.3.1", @@ -1288,6 +1289,21 @@ "win32" ] }, + "node_modules/@tonaljs/chord-type": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@tonaljs/chord-type/-/chord-type-5.1.0.tgz", + "integrity": "sha512-39/SRnhgvElvWHQH1mkOxkQvDodz7HRkPeIL2rjFV3JL57ve38Ws+y9bjbkKCbD474jF0SQ9V+Xga3d/Mccz5g==", + "license": "MIT", + "dependencies": { + "@tonaljs/pcset": "4.10.0" + } + }, + "node_modules/@tonaljs/collection": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/@tonaljs/collection/-/collection-4.9.0.tgz", + "integrity": "sha512-Mk0h7O54nT6PgNVcUYauzxa5KOB23+0AOKudWzRH7JhJIN9vhVIC7PtwZXE+/G051UTbHSFIcN/afkgF4nB/8A==", + "license": "MIT" + }, "node_modules/@tonaljs/midi": { "version": "4.10.0", "resolved": "https://registry.npmjs.org/@tonaljs/midi/-/midi-4.10.0.tgz", @@ -1310,6 +1326,19 @@ "@tonaljs/pitch-note": "6.0.0" } }, + "node_modules/@tonaljs/pcset": { + "version": "4.10.0", + "resolved": "https://registry.npmjs.org/@tonaljs/pcset/-/pcset-4.10.0.tgz", + "integrity": "sha512-kGiGDpN/fhJGJoILnNJIQqt2vpk8EkcXFtRvs3PaDfaMphtQ7Hd03d2zIhcEEShbCtaXUSeSwbCepwoGqt4WRw==", + "license": "MIT", + "dependencies": { + "@tonaljs/collection": "4.9.0", + "@tonaljs/pitch": "5.0.2", + "@tonaljs/pitch-distance": "5.0.4", + "@tonaljs/pitch-interval": "6.0.0", + "@tonaljs/pitch-note": "6.0.0" + } + }, "node_modules/@tonaljs/pitch": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/@tonaljs/pitch/-/pitch-5.0.2.tgz", @@ -1345,6 +1374,30 @@ "@tonaljs/pitch": "5.0.2" } }, + "node_modules/@tonaljs/scale": { + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/@tonaljs/scale/-/scale-4.13.0.tgz", + "integrity": "sha512-eCOoJlnjW542hCeW7yN61TEZkm7HTbBqdrug3Qx61o62tlwSwJF/nY00XgaTxWFpJFb9HEqAKuD7EUleqBnTYw==", + "license": "MIT", + "dependencies": { + "@tonaljs/chord-type": "5.1.0", + "@tonaljs/collection": "4.9.0", + "@tonaljs/note": "4.11.0", + "@tonaljs/pcset": "4.10.0", + "@tonaljs/pitch-distance": "5.0.4", + "@tonaljs/pitch-note": "6.0.0", + "@tonaljs/scale-type": "4.9.0" + } + }, + "node_modules/@tonaljs/scale-type": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/@tonaljs/scale-type/-/scale-type-4.9.0.tgz", + "integrity": "sha512-8kp71+gLAgKkLKd78SrLbP9eUdt1xKY/1uchgG7vyL4itMixMo/3l+WRKcvb0+gX2+AM2/E+xeCjxrRU5jaVpw==", + "license": "MIT", + "dependencies": { + "@tonaljs/pcset": "4.10.0" + } + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", diff --git a/package.json b/package.json index 7eaa827..7f705e2 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "dependencies": { "@react-hook/resize-observer": "^2.0.2", "@tonaljs/note": "^4.11.0", + "@tonaljs/scale": "^4.13.0", "classnames": "^2.5.1", "immer": "^10.1.1", "react": "^18.3.1", diff --git a/src/synth/Player.ts b/src/synth/Player.ts index a7975a4..bb37440 100644 --- a/src/synth/Player.ts +++ b/src/synth/Player.ts @@ -13,14 +13,13 @@ function startPlayer(context: AudioContext) { const nodeStates = getSoundNodeStates(); for (const [id, nodeState] of Object.entries(nodeStates)) { if (!Object.keys(soundNodes).includes(id)) { - soundNodes[id] = new SoundNode(id, context); + soundNodes[id] = new SoundNode(id, nodeState, context); subscribeToNodeState( id, debounce((nodeState) => soundNodes[id].updateState(nodeState)), ); - soundNodes[id].updateState(nodeState); } - soundNodes[id].nextState(nodeState); + soundNodes[id].loop(); } }); diff --git a/src/synth/SoundNode.ts b/src/synth/SoundNode.ts index e999d5c..69aa834 100644 --- a/src/synth/SoundNode.ts +++ b/src/synth/SoundNode.ts @@ -1,13 +1,15 @@ import { SoundNodeState } from "./SoundNodeState.ts"; import { beatEnd, beatStart } from "./bpm.ts"; +import * as Note from "@tonaljs/note"; export class SoundNode { id: string; + private _state: SoundNodeState; context: AudioContext; oscillator: OscillatorNode; gain: GainNode; - constructor(id: string, context: AudioContext) { + constructor(id: string, state: SoundNodeState, context: AudioContext) { this.id = id; this.context = context; this.oscillator = context.createOscillator(); @@ -18,14 +20,24 @@ export class SoundNode { this.oscillator.connect(this.gain); this.gain.connect(context.destination); + + this._state = state; + this.schedule(context.currentTime); } - schedule(time: number, state: SoundNodeState) { - const startTime = time + state.time; - const endTime = startTime + state.length; - this.oscillator.frequency.setValueAtTime(state.freq, startTime); - this.gain.gain.setValueAtTime(0.4, startTime); - this.gain.gain.setValueAtTime(0, endTime); + schedule(time: number) { + const startTime = time + this._state.time; + const endTime = startTime + this._state.length; + const freq = Note.freq(this._state.note); + if (freq === null) { + console.error(`Cannot determine frequency for note ${this._state.note}`); + return; + } + this.oscillator.frequency.linearRampToValueAtTime(freq, startTime); + this.gain.gain.setValueAtTime(0, startTime - 0.001); + this.gain.gain.linearRampToValueAtTime(0.4, startTime + 0.001); + this.gain.gain.setValueAtTime(0.4, endTime - 0.001); + this.gain.gain.linearRampToValueAtTime(0, endTime); } cancelScheduled(time: number) { @@ -34,16 +46,17 @@ export class SoundNode { } updateState(state: SoundNodeState) { + this._state = state; const currentBeatStart = beatStart(this.context.currentTime); - const nextBeatStart = beatStart(this.context.currentTime); + const nextBeatStart = beatEnd(this.context.currentTime); this.cancelScheduled(currentBeatStart); - this.schedule(currentBeatStart, state); - this.schedule(nextBeatStart, state); + this.schedule(currentBeatStart); + this.schedule(nextBeatStart); } - nextState(state: SoundNodeState) { + loop() { const nextBeatStart = beatEnd(this.context.currentTime); this.cancelScheduled(nextBeatStart); - this.schedule(nextBeatStart, state); + this.schedule(nextBeatStart); } } diff --git a/src/synth/SoundNodeState.ts b/src/synth/SoundNodeState.ts index e040b70..4a0b98a 100644 --- a/src/synth/SoundNodeState.ts +++ b/src/synth/SoundNodeState.ts @@ -1,18 +1,18 @@ import { Node, useAppStore } from "../App.store.ts"; import { posToTime } from "./bpm.ts"; import { mapObjectValues } from "../helpers.ts"; -import { posToFreq } from "./note.ts"; +import { posToNote } from "./note.ts"; export type SoundNodeState = { time: number; length: number; - freq: number; + note: string; }; function computeSoundNodeState(node: Node): SoundNodeState { return { time: posToTime(node.x), - freq: posToFreq(node.y + node.height / 2), + note: posToNote(node.y + node.height / 2), length: posToTime(node.width), }; } diff --git a/src/synth/note.ts b/src/synth/note.ts index 1f269d4..9eadb3b 100644 --- a/src/synth/note.ts +++ b/src/synth/note.ts @@ -1,10 +1,12 @@ -import * as Note from "@tonaljs/note"; +import * as Scale from "@tonaljs/scale"; import { useAppStore } from "../App.store.ts"; const availableNotes = 24; -const base = 60; +const base = 0; -export function posToFreq(pos: number) { +const c4major = Scale.steps("C4 mixolydian"); + +export function posToNote(pos: number) { const { size } = useAppStore.getState(); const height = size[1]; const noteMidi = @@ -15,9 +17,10 @@ export function posToFreq(pos: number) { 12 + Math.ceil(((height - pos) / height) * (availableNotes + 1)); - const freq = Note.freq(Note.fromMidi(noteMidi)); + console.log(noteMidi); + const freq = c4major(noteMidi); if (freq === null) { - throw new Error("could not determine note frequency!"); + throw new Error(`could not determine note frequency! ${noteMidi}`); } return freq; }