Last active
July 31, 2023 14:44
-
-
Save e111077/122bc08f20c15d126cd9d246f17b8cb0 to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<!DOCTYPE html> | |
<head> | |
<script type="module" src="./trom-bone.js"></script> | |
<style> | |
:root { | |
font-family: Arial; | |
} | |
</style> | |
</head> | |
<body> | |
<h1><trom-bone></h1> | |
<trom-bone></trom-bone> | |
</body> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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], | |
]); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
{ | |
"dependencies": { | |
"lit": "^2.0.0", | |
"@lit/reactive-element": "^1.0.0", | |
"lit-element": "^3.0.0", | |
"lit-html": "^2.0.0" | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | |
}`; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
{ | |
"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