Skip to content

Commit

Permalink
Prerender synth (#14)
Browse files Browse the repository at this point in the history
* Synth pre-renders into one player per note

* Refactor note playing into its own class

* Synth now cycles through multiple voices

* Notes get quieter with more played at once, to prevent crackling.

Closes #1. Also, added ghostMode false to gulpfile and increased particles count to 500.

* Implement tile glow on mouseover. Closes #8

* Fix touch events again
  • Loading branch information
MaxLaumeister committed Dec 22, 2019
1 parent f867a5a commit 4a10b69
Show file tree
Hide file tree
Showing 5 changed files with 142 additions and 50 deletions.
1 change: 1 addition & 0 deletions gulpfile.js
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ function serve(done) {
server: {
baseDir: './dist',
},
ghostMode: false,
});
done();
}
Expand Down
63 changes: 63 additions & 0 deletions src/NotePlayer.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
/* global Tone */
// eslint-disable-next-line no-unused-vars
class NotePlayer {
constructor(gridWidth, gridHeight) {
// Construct scale array
const pentatonic = ['B#', 'D', 'F', 'G', 'A'];
const octave = 3; // base octave
const octaveoffset = 4;
let scale = Array(gridHeight);
for (let i = 0; i < gridHeight; i += 1) {
scale[i] = pentatonic[i % pentatonic.length]
+ (octave + Math.floor((i + octaveoffset) / pentatonic.length));
}
scale = scale.reverse(); // higher notes at lower y values, near the top

// Pre-render synth

this.numVoices = 3; // Number of voices (players) *per note*

this.players = [];
// eslint-disable-next-line prefer-destructuring
const players = this.players;
scale.forEach((el, idx) => {
Tone.Offline(() => {
const lowPass = new Tone.Filter({
frequency: 1100,
rolloff: -12,
}).toMaster();

const synth = new Tone.PolySynth(16, Tone.Synth, {
oscillator: {
type: 'sine',
},
envelope: {
attack: 0.005,
decay: 0.1,
sustain: 0.3,
release: 1,
},
}).connect(lowPass);
synth.triggerAttackRelease(el, Tone.Time('1m') / gridWidth, 0);
}, (Tone.Time('1m') / gridWidth) * 6).then((buffer) => {
const voices = [];
for (let i = 0; i < this.numVoices; i += 1) {
voices.push(new Tone.Player(buffer).toMaster());
}
players[idx] = ({ voices, currentVoice: 0 });
});
});
}

play(index, time, volume) {
// Cycle through the note's voices
const note = this.players[index];
try {
note.voices[note.currentVoice].volume.setValueAtTime(volume, time);
note.voices[note.currentVoice].start(time);
note.currentVoice = (note.currentVoice + 1) % this.numVoices;
} catch (e) {
// Player not ready yet
}
}
}
2 changes: 1 addition & 1 deletion src/ParticleSystem.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ class ParticleSystem {
* @param {number} height - Width of the particle system, in pixels
*/
constructor(width, height) {
this.PARTICLE_POOL_SIZE = 200;
this.PARTICLE_POOL_SIZE = 500;
this.PARTICLE_LIFETIME = 40;

this.WIDTH = width;
Expand Down
125 changes: 76 additions & 49 deletions src/ToneMatrix.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
/* global Tone */
/* global ParticleSystem */
/* global SpriteSheet */
/* global NotePlayer */
// eslint-disable-next-line no-unused-vars
class ToneMatrix {
/**
Expand Down Expand Up @@ -48,6 +49,9 @@ class ToneMatrix {
*/
this.DPR = window.devicePixelRatio || 1;

this.mouseX = -1;
this.mouseY = -1;

// Get the size of the canvas in CSS pixels.
const rect = this.c.getBoundingClientRect();
// Give the canvas pixel dimensions of their CSS
Expand All @@ -73,14 +77,7 @@ class ToneMatrix {

let arming = null;

function canvasClick(e) {
const currentRect = this.c.getBoundingClientRect(); // abs. size of element
const scaleX = this.c.width / currentRect.width; // relationship bitmap vs. element for X
const scaleY = this.c.height / currentRect.height; // relationship bitmap vs. element for Y

const x = (e.clientX - currentRect.left) * scaleX;
const y = (e.clientY - currentRect.top) * scaleY;

function canvasClick(x, y) {
const tile = this.getTileCollision(x, y);
if (arming === null) arming = !this.getTileValue(tile.x, tile.y);
this.setTileValue(tile.x, tile.y, arming);
Expand All @@ -89,64 +86,50 @@ class ToneMatrix {
this.setSharingURL(base64);
}
this.c.addEventListener('mousemove', (e) => {
const { x, y } = this.setCanvasMousePosition(e);
if (e.buttons !== 1) return; // Only if left button is held
canvasClick.bind(this)(e);
canvasClick.bind(this)(x, y);
});
this.c.addEventListener('mouseleave', () => {
this.resetCanvasMousePosition();
});
this.c.addEventListener('mousedown', (e) => {
const { x, y } = this.setCanvasMousePosition(e);
arming = null;
canvasClick.bind(this)(e);
canvasClick.bind(this)(x, y);
});
this.c.addEventListener('touchstart', (e) => {
e.preventDefault(); // Prevent emulated click
if (e.touches.length === 1) {
arming = null;
}
Array.from(e.touches).forEach((touch) => canvasClick.bind(this)(touch));
Array.from(e.touches).forEach(
(touch) => {
const { x, y } = this.setCanvasMousePosition(touch);
canvasClick.bind(this)(x, y);
},
);
});
this.c.addEventListener('touchmove', (e) => {
e.preventDefault(); // Prevent emulated click
Array.from(e.touches).forEach((touch) => canvasClick.bind(this)(touch));
Array.from(e.touches).forEach(
(touch) => {
const { x, y } = this.setCanvasMousePosition(touch);
canvasClick.bind(this)(x, y);
},
);
});

// Construct scale array

const pentatonic = ['B#', 'D', 'F', 'G', 'A'];
const octave = 3; // base octave
const octaveoffset = 4;
this.scale = Array(this.HEIGHT);
for (let i = 0; i < this.HEIGHT; i += 1) {
this.scale[i] = pentatonic[i % pentatonic.length]
+ (octave + Math.floor((i + octaveoffset) / pentatonic.length));
}
this.scale = this.scale.reverse(); // higher notes at lower y values, near the top

// Init synth

const lowPass = new Tone.Filter({
frequency: 1100,
rolloff: -12,
}).toMaster();

this.synth = new Tone.PolySynth(16, Tone.Synth, {
oscillator: {
type: 'sine',
},
envelope: {
attack: 0.005,
decay: 0.1,
sustain: 0.3,
release: 1,
},
}).connect(lowPass);

this.synth.volume.value = -10;

this.SYNTHLATENCY = 0.25; // Queue events ahead of time
Tone.context.latencyHint = this.SYNTHLATENCY;
Tone.Transport.loopEnd = '1m'; // loop at one measure
Tone.Transport.loop = true;
Tone.Transport.toggle(); // start

// Start audio system

this.notePlayer = new NotePlayer(this.WIDTH, this.HEIGHT);

// Init particle system

this.particleSystem = new ParticleSystem(this.c.width, this.c.height);
Expand Down Expand Up @@ -200,6 +183,27 @@ class ToneMatrix {
drawContinuous();
}

setCanvasMousePosition(e) {
const currentRect = this.c.getBoundingClientRect(); // abs. size of element
const scaleX = this.c.width / currentRect.width; // relationship bitmap vs. element for X
const scaleY = this.c.height / currentRect.height; // relationship bitmap vs. element for Y

const x = (e.clientX - currentRect.left) * scaleX;
const y = (e.clientY - currentRect.top) * scaleY;

// Update internal position
this.mouseX = x;
this.mouseY = y;

return { x, y };
}

resetCanvasMousePosition() {
// Update internal position
this.mouseX = -1;
this.mouseY = -1;
}

/**
* Clear all tiles and resets the sharing URL.
*/
Expand Down Expand Up @@ -244,8 +248,15 @@ class ToneMatrix {
// Make sure AudioContext has started
Tone.context.resume();
// Turning on, schedule note

const highVolume = -10; // When one note is playing
const lowVolume = -35; // When all notes are playing (lower volume to prevent peaking)

const volume = ((this.HEIGHT - this.countNotesInColumn(x)) / this.HEIGHT)
* (highVolume - lowVolume) + lowVolume;

this.data[x * this.WIDTH + y] = Tone.Transport.schedule((time) => {
this.synth.triggerAttackRelease(this.scale[y], Tone.Time('1m') / this.WIDTH, time);
this.notePlayer.play(y, time, volume);
}, (Tone.Time('1m') / this.WIDTH) * x);
} else {
if (!this.getTileValue(x, y)) return;
Expand All @@ -255,6 +266,14 @@ class ToneMatrix {
}
}

countNotesInColumn(x) {
let count = 0;
for (let i = 0; i < this.HEIGHT; i += 1) {
if (this.getTileValue(x, i)) count += 1;
}
return count;
}

/**
* Toggle whether a grid tile is currently lit up (armed)
* @param {number} x - The x position, measured in grid tiles
Expand Down Expand Up @@ -291,6 +310,9 @@ class ToneMatrix {
const adjustedProgress = adjustedSeconds / (Tone.Transport.loopEnd - Tone.Transport.loopStart);

const playheadx = Math.floor(adjustedProgress * this.WIDTH);

const mousedOverTile = this.getTileCollision(this.mouseX, this.mouseY);

// Draw each tile
for (let i = 0; i < this.data.length; i += 1) {
const dx = this.c.height / this.HEIGHT;
Expand Down Expand Up @@ -321,9 +343,14 @@ class ToneMatrix {
this.ctx.drawImage(this.spriteSheet.get(), dx, 0, dx, dy, x, y, dx, dy);
}
} else {
const BRIGHTNESS = 0.05; // max particle brightness between 0 and 1
this.ctx.globalAlpha = ((heatmap[i] * BRIGHTNESS * (204 / 255))
/ this.particleSystem.PARTICLE_LIFETIME) + 51 / 255;
if (gridx === mousedOverTile.x && gridy === mousedOverTile.y) {
// Highlight moused over tile
this.ctx.globalAlpha = 0.3;
} else {
const BRIGHTNESS = 0.05; // max particle brightness between 0 and 1
this.ctx.globalAlpha = ((heatmap[i] * BRIGHTNESS * (204 / 255))
/ this.particleSystem.PARTICLE_LIFETIME) + 51 / 255;
}
this.ctx.drawImage(this.spriteSheet.get(), 0, 0, dx, dy, x, y, dx, dy);
}
}
Expand Down
1 change: 1 addition & 0 deletions src/style.scss
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ h1 {

canvas {
margin: -4px 0;
cursor: pointer;
}

.canvaswrap {
Expand Down

0 comments on commit 4a10b69

Please sign in to comment.