diff --git a/src/samples/synth-frequencies.ts b/src/samples/synth-frequencies.ts new file mode 100644 index 0000000..3d8c7a2 --- /dev/null +++ b/src/samples/synth-frequencies.ts @@ -0,0 +1,120 @@ +// copied from https://developer.mozilla.org/en-US/docs/Web/API/Web_Audio_API/Simple_synth + +/* eslint-disable no-loss-of-precision */ + +type Note = 'A' | 'A#' | 'B' | 'C' | 'C#' | 'D' | 'D#' | 'E' | 'F' | 'F#' | 'G' | 'G#'; + +export type NoteFreq = { + [key in Note]: number; +} + +function createNoteTable() { + const noteFreq: Partial[] = []; + for (let i=0; i< 9; i++) { + noteFreq[i] = {}; + } + + noteFreq[0]['A'] = 27.500000000000000; + noteFreq[0]['A#'] = 29.135235094880619; + noteFreq[0]['B'] = 30.867706328507756; + + noteFreq[1]['C'] = 32.703195662574829; + noteFreq[1]['C#'] = 34.647828872109012; + noteFreq[1]['D'] = 36.708095989675945; + noteFreq[1]['D#'] = 38.890872965260113; + noteFreq[1]['E'] = 41.203444614108741; + noteFreq[1]['F'] = 43.653528929125485; + noteFreq[1]['F#'] = 46.249302838954299; + noteFreq[1]['G'] = 48.999429497718661; + noteFreq[1]['G#'] = 51.913087197493142; + noteFreq[1]['A'] = 55.000000000000000; + noteFreq[1]['A#'] = 58.270470189761239; + noteFreq[1]['B'] = 61.735412657015513; + // … + + noteFreq[2]['C'] = 65.406391325149658; + noteFreq[2]['C#'] = 69.295657744218024; + noteFreq[2]['D'] = 73.41619197935189; + noteFreq[2]['D#'] = 77.781745930520227; + noteFreq[2]['E'] = 82.406889228217482; + noteFreq[2]['F'] = 87.307057858250971; + noteFreq[2]['F#'] = 92.498605677908599; + noteFreq[2]['G'] = 97.998858995437323; + noteFreq[2]['G#'] = 103.826174394986284; + noteFreq[2]['A'] = 110.0; + noteFreq[2]['A#'] = 116.540940379522479; + noteFreq[2]['B'] = 123.470825314031027; + + noteFreq[3]['C'] = 130.812782650299317; + noteFreq[3]['C#'] = 138.591315488436048; + noteFreq[3]['D'] = 146.83238395870378; + noteFreq[3]['D#'] = 155.563491861040455; + noteFreq[3]['E'] = 164.813778456434964; + noteFreq[3]['F'] = 174.614115716501942; + noteFreq[3]['F#'] = 184.997211355817199; + noteFreq[3]['G'] = 195.997717990874647; + noteFreq[3]['G#'] = 207.652348789972569; + noteFreq[3]['A'] = 220.0; + noteFreq[3]['A#'] = 233.081880759044958; + noteFreq[3]['B'] = 246.941650628062055; + + noteFreq[4]['C'] = 261.625565300598634; + noteFreq[4]['C#'] = 277.182630976872096; + noteFreq[4]['D'] = 293.66476791740756; + noteFreq[4]['D#'] = 311.12698372208091; + noteFreq[4]['E'] = 329.627556912869929; + noteFreq[4]['F'] = 349.228231433003884; + noteFreq[4]['F#'] = 369.994422711634398; + noteFreq[4]['G'] = 391.995435981749294; + noteFreq[4]['G#'] = 415.304697579945138; + noteFreq[4]['A'] = 440.0; + noteFreq[4]['A#'] = 466.163761518089916; + noteFreq[4]['B'] = 493.883301256124111; + + noteFreq[5]['C'] = 523.251130601197269; + noteFreq[5]['C#'] = 554.365261953744192; + noteFreq[5]['D'] = 587.32953583481512; + noteFreq[5]['D#'] = 622.253967444161821; + noteFreq[5]['E'] = 659.255113825739859; + noteFreq[5]['F'] = 698.456462866007768; + noteFreq[5]['F#'] = 739.988845423268797; + noteFreq[5]['G'] = 783.990871963498588; + noteFreq[5]['G#'] = 830.609395159890277; + noteFreq[5]['A'] = 880.0; + noteFreq[5]['A#'] = 932.327523036179832; + noteFreq[5]['B'] = 987.766602512248223; + + noteFreq[6]['C'] = 1046.502261202394538; + noteFreq[6]['C#'] = 1108.730523907488384; + noteFreq[6]['D'] = 1174.659071669630241; + noteFreq[6]['D#'] = 1244.507934888323642; + noteFreq[6]['E'] = 1318.510227651479718; + noteFreq[6]['F'] = 1396.912925732015537; + noteFreq[6]['F#'] = 1479.977690846537595; + noteFreq[6]['G'] = 1567.981743926997176; + noteFreq[6]['G#'] = 1661.218790319780554; + noteFreq[6]['A'] = 1760.0; + noteFreq[6]['A#'] = 1864.655046072359665; + noteFreq[6]['B'] = 1975.533205024496447; + + noteFreq[7]['C'] = 2093.004522404789077; + noteFreq[7]['C#'] = 2217.461047814976769; + noteFreq[7]['D'] = 2349.318143339260482; + noteFreq[7]['D#'] = 2489.015869776647285; + noteFreq[7]['E'] = 2637.020455302959437; + noteFreq[7]['F'] = 2793.825851464031075; + noteFreq[7]['F#'] = 2959.955381693075191; + noteFreq[7]['G'] = 3135.963487853994352; + noteFreq[7]['G#'] = 3322.437580639561108; + noteFreq[7]['A'] = 3520.000000000000000; + noteFreq[7]['A#'] = 3729.310092144719331; + noteFreq[7]['B'] = 3951.066410048992894; + + noteFreq[8]['C'] = 4186.009044809578154; + return noteFreq; +} + +export const notesFrequencies = createNoteTable(); +export const allFrequencies = notesFrequencies + .flatMap(note => Object.values(note)) + .sort((a, b) => a - b); diff --git a/src/scenes/EmptyScene.ts b/src/scenes/EmptyScene.ts index d56b5e9..dbd2603 100644 --- a/src/scenes/EmptyScene.ts +++ b/src/scenes/EmptyScene.ts @@ -5,6 +5,7 @@ import {hexToColor} from '../utils/colors.ts'; import {FontFamily} from '../utils/fonts.ts'; import {DrumsScene} from './DrumsScene.ts'; import {GibberishScene} from './GiberishScene.ts'; +import {SimpleSynthScene} from './SimpleSynthScene.ts'; export class EmptyScene extends Phaser.Scene { static key = 'EmptyScene'; @@ -24,6 +25,9 @@ export class EmptyScene extends Phaser.Scene { this.createMatrix(); + const simpleSynthButton = this.instrumentButtons[2][1]; + simpleSynthButton.setData('text', this.addText(simpleSynthButton, 'Simple Synth')); + const drumsButton = this.instrumentButtons[1][2]; drumsButton.setData('text', this.addText(drumsButton, 'Drums')); @@ -37,6 +41,7 @@ export class EmptyScene extends Phaser.Scene { drumsButton, otherDrumsButton, gibberishButton, + simpleSynthButton ]; activeButtons.forEach(button => button .setFillStyle(hexToColor('#FFF'), 0.5) @@ -56,6 +61,9 @@ export class EmptyScene extends Phaser.Scene { gibberishButton.on(Phaser.Input.Events.POINTER_UP, () => { this.scene.add(trackSceneKey, GibberishScene, true, {numberOfPads: 8}); }); + simpleSynthButton.on(Phaser.Input.Events.POINTER_UP, () => { + this.scene.add(trackSceneKey, SimpleSynthScene, true); + }); window.addEventListener('resize', () => this.resizeScene()); this.resizeScene(); diff --git a/src/scenes/PadsScene.ts b/src/scenes/PadsScene.ts new file mode 100644 index 0000000..040cfab --- /dev/null +++ b/src/scenes/PadsScene.ts @@ -0,0 +1,78 @@ +import Phaser from 'phaser'; +import {LoopTracksScene} from './LoopTracksScene.ts'; +import {rotateArray} from '../utils/math.ts'; +import {hexToColor} from '../utils/colors.ts'; + +type Pad = { + instrument: number, + button: Phaser.GameObjects.Rectangle, +} +export abstract class PadsScene extends Phaser.Scene { + + private pads: Pad[] = []; + protected constructor(private cols: number, private rows: number) { + super(); + } + + abstract playSound(index: number): void; + + create() { + this.createPads(); + this.scene.get(LoopTracksScene.key).events.emit('track-selected'); + } + + protected createPads(){ + const numberOfPads = this.cols * this.rows; + this.pads = new Array(numberOfPads).fill(0).map((_, index) => { + return this.createPad(index, numberOfPads); + }); + + const resizePads = () => { + const isPortrait = window.innerWidth < window.innerHeight; + const colNumber = isPortrait ? this.cols : this.rows; + const rowNumber = isPortrait ? this.rows : this.cols; + const width = isPortrait ? window.innerWidth / colNumber : (window.innerWidth - LoopTracksScene.sceneWidthHeight) / colNumber; + const height = isPortrait ? (window.innerHeight - LoopTracksScene.sceneWidthHeight) / rowNumber : window.innerHeight / rowNumber; + + const currentPads = isPortrait ? rotateArray(this.pads, rowNumber, colNumber): this.pads; + + currentPads.forEach(({ button }, index) => { + const x = (index % colNumber) * width; + const y = Math.floor(index / colNumber) * height; + const offsetX = isPortrait ? 0 : LoopTracksScene.sceneWidthHeight; + const offsetY = isPortrait ? LoopTracksScene.sceneWidthHeight : 0; + button.setSize(width, height).setPosition(offsetX + x, offsetY + y); + }); + }; + + // Attach the event listener and initial call + window.addEventListener('resize', resizePads); + resizePads(); + } + + protected createPad(index: number, numberOfPads: number): Pad { + const padColor = Phaser.Display.Color.HSLToColor((numberOfPads - index) / (numberOfPads * 1.5), 1, 0.5) + const inactiveColor = padColor.darken(40).color; + const hitColor = padColor.brighten(4).color; + const button = this.add.rectangle() + .setFillStyle(inactiveColor) + .setStrokeStyle(2, hexToColor('#FFF'), 0.8) + .setInteractive() + .setOrigin(0, 0); + button.on('pointerdown', (e: Phaser.Input.Pointer) => { + if (e.downElement?.tagName?.toLowerCase() !== 'canvas') { + return; + } + this.playSound(index); + button.setFillStyle(hitColor); + // TODO send a call to the loop scene to play the instrument ? or an object + // this.scene.get(LoopTracksScene.key).events.emit('instrument-played', {instrument, scene: this}); + }).on('pointerup', () => button.setFillStyle(inactiveColor)) + .on('pointerout', () => button.setFillStyle(inactiveColor)); + return { + instrument: index, + button + }; + } + +} diff --git a/src/scenes/SimpleSynthScene.ts b/src/scenes/SimpleSynthScene.ts new file mode 100644 index 0000000..d68fd70 --- /dev/null +++ b/src/scenes/SimpleSynthScene.ts @@ -0,0 +1,31 @@ +import {PadsScene} from './PadsScene.ts'; +import {allFrequencies} from '../samples/synth-frequencies.ts'; +import {createAudioContext} from '../samples/sample-utils.ts'; + +export class SimpleSynthScene extends PadsScene { + + constructor() { + super(8, 11); + } + + create() { + super.create(); + } + + playSound(index: number): void { + const note = allFrequencies[index]; + console.log('Playing note', note); + // play the sound + const audioContext = createAudioContext(); + const oscillator = audioContext.createOscillator(); + oscillator.type = 'sine'; + oscillator.frequency.setValueAtTime(note, audioContext.currentTime); + const gainNode = audioContext.createGain(); + gainNode.gain.setValueAtTime(1, audioContext.currentTime); // Start loud + gainNode.gain.exponentialRampToValueAtTime(0.001, audioContext.currentTime + 1.5); // Long decay (1.5 seconds) + oscillator.connect(gainNode); + gainNode.connect(audioContext.destination); + oscillator.start(); + oscillator.stop(audioContext.currentTime + 0.5); + } +}