Skip to content

Instantly share code, notes, and snippets.

@e111077
Last active July 31, 2023 14:44
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 2 You must be signed in to fork a gist
  • Save e111077/122bc08f20c15d126cd9d246f17b8cb0 to your computer and use it in GitHub Desktop.
Save e111077/122bc08f20c15d126cd9d246f17b8cb0 to your computer and use it in GitHub Desktop.
<!DOCTYPE html>
<head>
<script type="module" src="./trom-bone.js"></script>
<style>
:root {
font-family: Arial;
}
</style>
</head>
<body>
<h1>&lt;trom-bone&gt;</h1>
<trom-bone></trom-bone>
</body>
export type OscillatorType = 'triangle' | 'sine' | 'square' | 'sawtooth';
export interface SynthConfig {
volume: number;
type: OscillatorType;
frequency: number;
}
// defaut configuration
const defaultConfig: SynthConfig = {
volume: 2,
type: 'triangle',
frequency: 440,
};
// Small synthesizer using the Web Audio API.
export class MiniSynth {
// Create audio context and nodes
private _ctx = new AudioContext();
private _osc = this._ctx.createOscillator();
private _vol = this._ctx.createGain();
private _targetVolume: number;
constructor(conf?: Partial<SynthConfig>) {
const config: SynthConfig = {...defaultConfig, ...conf};
this.frequency = config.frequency;
this.type = config.type;
this.volume = 0;
// this.volume refers to the actual, current volume.
// _targetVolume is the volume the user wants it to be
this._targetVolume = config.volume;
// connect the oscillator to the gain node
this._osc.connect(this._vol);
// connect the gain node to the speakers
this._vol.connect(this._ctx.destination);
// audio cannot start withoutuser interaction
window.addEventListener('pointerdown', () => {
this._osc.start(0);
}, {once: true});
}
// immediately sets the gain on the GainNode
set volume(value) {
this._vol.gain.value = value;
}
// returns the volume the user wants it to be
get volume() {
return this._targetVolume;
}
// sets the frequency of the oscillator (the note)
set frequency(value) {
this._osc.frequency.value = value;
}
// returns the current frequency
get frequency() {
return this._osc.frequency.value;
}
// immediately sets the gain on the GainNode
set type(value) {
this._osc.type = value;
}
// returns the volume the user wants it to be
get type() {
return this._osc.type;
}
play() {
// make sure volume is muted
this.volume = 0;
// set the volume to the target in 10 ms so human ears don't hear a "pop"
// http://alemangui.github.io/ramp-to-value
this._vol.gain.setTargetAtTime(
this._targetVolume,
this._ctx.currentTime,
0.01
);
}
stop() {
// set the volume to 0 in 10 ms so the human ears don't hear a "pop"
// http://alemangui.github.io/ramp-to-value
this._vol.gain.setTargetAtTime(0, this._ctx.currentTime, 0.01);
}
}
// note to frequency JSON pairing without the flats (b) for byte saving
export const NOTES_TO_FREQ = new Map ([
['C0', 16.35],
['C#0', 17.32],
['D0', 18.35],
['D#0', 19.45],
['E0', 20.6],
['F0', 21.83],
['F#0', 23.12],
['G0', 24.5],
['G#0', 25.96],
['A0', 27.5],
['A#0', 29.14],
['B0', 30.87],
['C1', 32.7],
['C#1', 34.65],
['D1', 36.71],
['D#1', 38.89],
['E1', 41.2],
['F1', 43.65],
['F#1', 46.25],
['G1', 49.0],
['G#1', 51.91],
['A1', 55.0],
['A#1', 58.27],
['B1', 61.74],
['C2', 65.41],
['C#2', 69.3],
['D2', 73.42],
['D#2', 77.78],
['E2', 82.41],
['F2', 87.31],
['F#2', 92.5],
['G2', 98.0],
['G#2', 103.83],
['A2', 110.0],
['A#2', 116.54],
['B2', 123.47],
['C3', 130.81],
['C#3', 138.59],
['D3', 146.83],
['D#3', 155.56],
['E3', 164.81],
['F3', 174.61],
['F#3', 185.0],
['G3', 196.0],
['G#3', 207.65],
['A3', 220.0],
['A#3', 233.08],
['B3', 246.94],
['C4', 261.63],
['C#4', 277.18],
['D4', 293.66],
['D#4', 311.13],
['E4', 329.63],
['F4', 349.23],
['F#4', 369.99],
['G4', 392.0],
['G#4', 415.3],
['A4', 440.0],
['A#4', 466.16],
['B4', 493.88],
['C5', 523.25],
['C#5', 554.37],
['D5', 587.33],
['D#5', 622.25],
['E5', 659.26],
['F5', 698.46],
['F#5', 739.99],
['G5', 783.99],
['G#5', 830.61],
['A5', 880.0],
['A#5', 932.33],
['B5', 987.77],
['C6', 1046.5],
['C#6', 1108.73],
['D6', 1174.66],
['D#6', 1244.51],
['E6', 1318.51],
['F6', 1396.91],
['F#6', 1479.98],
['G6', 1567.98],
['G#6', 1661.22],
['A6', 1760.0],
['A#6', 1864.66],
['B6', 1975.53],
['C7', 2093.0],
['C#7', 2217.46],
['D7', 2349.32],
['D#7', 2489.02],
['E7', 2637.02],
['F7', 2793.83],
['F#7', 2959.96],
['G7', 3135.96],
['G#7', 3322.44],
['A7', 3520.0],
['A#7', 3729.31],
['B7', 3951.07],
['C8', 4186.01],
['C#8', 4434.92],
['D8', 4698.64],
['D#8', 4978.03],
]);
import {html, LitElement, PropertyValues} from 'lit';
import {customElement, property} from 'lit/decorators.js';
import {
generatePlayingTemplate,
generateTacetTemplate,
notePlayerStyles,
} from './templates-and-styles.js';
import {MiniSynth, NOTES_TO_FREQ} from './mini-synth.js';
@customElement('note-player')
export class NotePlayer extends LitElement {
// declare reactive properties
@property({type: String}) note: string | undefined;
@property({type: Number}) octave: number | undefined;
@property({type: Boolean}) playing = false;
// generate a synth
private _synth = new MiniSynth();
// before each render, determine whether the synth's note should change an set it
// also play or stop the synth from playing if the `playing` property has been set
willUpdate(changed: PropertyValues<this>) {
const hasNoteChanged = changed.has('note') && changed.get('note') !== undefined;
const hasOctaveChanged = changed.has('octave') && changed.get('octave') !== undefined;
const areNoteAndOctaveDefined = this.note && this.octave !== undefined;
// Determine whether to change the synth note
if (this.note && (hasNoteChanged || (hasOctaveChanged && areNoteAndOctaveDefined))) {
this._synth.frequency = NOTES_TO_FREQ.get(`${this.note.toUpperCase()}${this.octave}`)!;
}
// determine whether to play or stop playing the synth
if (changed.has('playing')) {
const note = this.note ?? 'C';
const octave = this.octave === undefined ? 4 : this.octave;
if (this.playing) {
this._synth.frequency = NOTES_TO_FREQ.get(`${note.toUpperCase()}${octave}`)!;
this._synth.play();
} else {
this._synth.stop();
}
}
}
// render our templates!
render() {
if (this.playing) {
return html`<pre>${generatePlayingTemplate(this.note, this.octave)}</pre>`;
}
return html`<pre>${generateTacetTemplate(this.note, this.octave)}</pre>`;
}
// Style our component!
static styles = notePlayerStyles;
}
{
"dependencies": {
"lit": "^2.0.0",
"@lit/reactive-element": "^1.0.0",
"lit-element": "^3.0.0",
"lit-html": "^2.0.0"
}
}
import {html, css} from 'lit';
export const hornUnicode = html`
████
██ ██
████ ▓▓
██████ ▓▓
██████████████████████ ▓▓
██ ▓▓
██ ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░ ▓▓
██ ██ ▒▒ ▒▒ ▒▒▒▒▒▒░░░░░░ ▒▒
██ ██ ▒▒ ▒▒ ▒▒▒▒░░ ▒▒
██ ██ ▒▒ ▒▒ ▒▒░░▒▒
██████████████████████████████████████████████████████████████████████████
▒▒░░ ▒▒ ▒▒
██████████████████████████████████████████████████████████████████████`;
export const slideUnicode = html`
██████████████████████████████████████████████████████████████████████
██ ██ ████
██████████████████████████████████████████████████████████████████ ██
██░░██████████████████████████████████████████████████░░██
██░░░░██ ░░ ░░░░████
██░░██████████████████████████████████████████████████
████`;
export const generatePlayingTemplate = (note, octave) => html`
⎸♫♩♬🎶
⎸♬♩♪
⎸🎶 ${note}${octave}
⎸♩♪♫
⎸♪🎶♫♩`;
export const generateTacetTemplate = (note, octave) => html`
⎸ ${note}${octave}
⎸`;
export const tromBoneStyles = css`
.relative {
position: relative;
}
pre {
margin: 0;
user-select: none;
}
trom-bone-slide {
position: absolute;
inset-block-start: -61px;
}
note-player {
position: absolute;
inset-inline-start: 340px;
}`;
const RANGE_SLIDER_WIDTH = css`520px`;
const UNICODE_SLIDER_HEIGHT = css`120px`;
export const RANGE_SLIDER_WIDTH_PX = 520;
export const tromBoneSlideStyles = css`
:host {
display: block;
}
#wrapper {
position: relative;
width: 610px;
}
input {
width: ${RANGE_SLIDER_WIDTH};
height: ${UNICODE_SLIDER_HEIGHT};
position: absolute;
margin: 0;
opacity: .0001;
}
pre {
margin: 0;
position: absolute;
user-select: none;
}
#markings {
display: flex;
}
#markings > * {
display: inline-flex;
position: relative;
justify-content: center;
align-items: center;
line-height: 30px;
width: 30px;
height: 30px;
}
#markings > *:before {
position: absolute;
inset-block-start: 0;
inset-inline-start: 0;
content: '|';
}
#markings > *:first-child:before {
content: '';
}`;
export const notePlayerStyles = css`
pre {
margin: 0;
font-size: 1.63em;
user-select: none;
}`;
import {html, LitElement} from 'lit';
import {customElement, state} from 'lit/decorators.js';
import {slideUnicode, tromBoneSlideStyles, RANGE_SLIDER_WIDTH_PX} from './templates-and-styles.js';
// Initializing some constants for readability
const NOTE_ARRAY: [string,number][] = [["C",4],["B",3],["A#",3],["A",3],["G#",3],["G",3],["F#",3],["F",3],["E",3],["D#",3],["D",3],["C#",3],["C",3]];
const NUM_NOTES = NOTE_ARRAY.length;
@customElement('trom-bone-slide')
export class TromBoneSlide extends LitElement {
// declare reactive properties
@state() private left = 0;
// declare public, non-reactive properties
note = 'C';
octave = 4;
// render template
render() {
return html`
<div id="wrapper">
<pre style="transform: translateX(${this.left}px)">${slideUnicode}</pre>
<!-- Input range slider is just displayed on top of the trombone slider unicode -->
<input
@input=${this._onInput}
@pointerdown=${this._onPointerDown}
@pointerup=${this._onPointerUp}
type="range"
min="0"
max=${NUM_NOTES}
step=".1"
value="0"/>
</div>`;
}
// Handle range slider change by calculating the position of the unicode slider
// as well as the new note and octave
private _onInput(event: Event) {
const rawValue = Number((event.target as HTMLInputElement).value);
// calculate the left offset of the trombone unicode slider
this.left = Number(rawValue) / NUM_NOTES * RANGE_SLIDER_WIDTH_PX;
const [note, octave] = TromBoneSlide.valueToNoteAndOctave(rawValue);
const hasChanged = this.note !== note || this.octave !== octave;
if (hasChanged) {
this.note = note;
this.octave = octave;
this.dispatchEvent(new Event('note-changed'));
}
}
// Handle the user's pointer down on the slider
private _onPointerDown(event: PointerEvent) {
this.dispatchEvent(new Event('interaction-start'));
(event.target as HTMLInputElement).setPointerCapture(event.pointerId);
}
// Handle the user's pointer release on the slider
private _onPointerUp(event: PointerEvent) {
this.dispatchEvent(new Event('interaction-end'));
(event.target as HTMLInputElement).releasePointerCapture(event.pointerId);
}
// Convert the raw value of the slider into a note and an octave
static valueToNoteAndOctave (rawValue: number) {
const floored = Math.floor(rawValue);
const quantizedValue = floored === NUM_NOTES ? NUM_NOTES - 1 : floored;
return NOTE_ARRAY[quantizedValue];
}
// styles!
static styles = tromBoneSlideStyles;
}
import {html, LitElement} from 'lit';
import {customElement, property} from 'lit/decorators.js';
import {hornUnicode, tromBoneStyles} from './templates-and-styles.js';
import {TromBoneSlide} from './trom-bone-slide.js';
import './trom-bone-slide.js';
import './note-player.js';
@customElement('trom-bone')
export class TromBone extends LitElement {
// Declare your reactive properties
@property({type: String}) note: string|undefined;
@property({type: Number}) octave: number|undefined;
@property({type: Boolean}) playing = false;
// render the template
render() {
return html`
<div class="relative">
<note-player
.note=${this.note}
.octave=${this.octave}
.playing=${this.playing}>
</note-player>
</div>
<pre>${hornUnicode}</pre>
<div class="relative">
<trom-bone-slide
@note-changed=${this._onNoteChanged}
@interaction-start=${() => {this.playing = true}}
@interaction-end=${() => {this.playing = false}}>
</trom-bone-slide>
</div>`
}
private _onNoteChanged(event: Event) {
const target = event.target as TromBoneSlide;
this.note = target.note;
this.octave = target.octave;
}
// styles
static styles = tromBoneStyles;
}
{
"files": {
"trom-bone.ts": {
"position": 0
},
"index.html": {
"position": 1
},
"package.json": {
"position": 2,
"hidden": true
},
"trom-bone-slide.ts": {
"position": 3
},
"note-player.ts": {
"position": 4
},
"templates-and-styles.ts": {
"position": 5
},
"mini-synth.ts": {
"position": 6
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment