@greenblacksky/honmoon 0.0.0-stage → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +81 -2
- package/package.json +58 -4
- package/src/lib/audio/context.ts +29 -0
- package/src/lib/audio/metronome.svelte.ts +146 -0
- package/src/lib/audio/mic.ts +254 -0
- package/src/lib/audio/pitch.ts +133 -0
- package/src/lib/audio/rhythmplay.ts +152 -0
- package/src/lib/audio/synth.ts +409 -0
- package/src/lib/audio/voiceplay.ts +60 -0
- package/src/lib/drills/defs.ts +459 -0
- package/src/lib/drills/questions.ts +920 -0
- package/src/lib/drills/registry.svelte.ts +167 -0
- package/src/lib/drills/runtime.svelte.ts +629 -0
- package/src/lib/drills/verdict.ts +241 -0
- package/src/lib/index.ts +117 -0
- package/src/lib/learn/describe.ts +72 -0
- package/src/lib/learn/pools.ts +44 -0
- package/src/lib/learn/progress.ts +100 -0
- package/src/lib/learn/session.ts +304 -0
- package/src/lib/music/bass.ts +139 -0
- package/src/lib/music/clefs.ts +218 -0
- package/src/lib/music/intervals.ts +94 -0
- package/src/lib/music/notes.ts +133 -0
- package/src/lib/music/rhythm.ts +624 -0
- package/src/lib/music/scales.ts +560 -0
- package/src/lib/music/voice.ts +227 -0
- package/src/lib/music/warmups.ts +318 -0
- package/src/lib/nav.svelte.ts +53 -0
- package/src/lib/notation/fretboard.ts +187 -0
- package/src/lib/notation/glyphdata.ts +101 -0
- package/src/lib/notation/glyphs.ts +71 -0
- package/src/lib/notation/icons.ts +12 -0
- package/src/lib/notation/keyboard.ts +166 -0
- package/src/lib/notation/rhythmsvg.ts +341 -0
- package/src/lib/notation/staff.ts +288 -0
- package/src/lib/notation/svg.ts +37 -0
- package/src/lib/notation/tab.ts +80 -0
- package/src/lib/store/honmoon.svelte.ts +329 -0
- package/src/lib/store/normalize.ts +23 -0
- package/src/lib/store/sync.ts +43 -0
- package/src/lib/store/types.ts +101 -0
- package/src/lib/styles/honmoon.css +2064 -0
- package/src/lib/ui/AccRow.svelte +23 -0
- package/src/lib/ui/BackBar.svelte +23 -0
- package/src/lib/ui/BassRow.svelte +30 -0
- package/src/lib/ui/Chips.svelte +23 -0
- package/src/lib/ui/DataCard.svelte +66 -0
- package/src/lib/ui/Fretboard.svelte +71 -0
- package/src/lib/ui/Keyboard.svelte +54 -0
- package/src/lib/ui/MetronomePanel.svelte +101 -0
- package/src/lib/ui/RefBar.svelte +28 -0
- package/src/lib/ui/RhythmBar.svelte +9 -0
- package/src/lib/ui/Shell.svelte +96 -0
- package/src/lib/ui/StaffRun.svelte +46 -0
- package/src/lib/ui/Stat.svelte +13 -0
- package/src/lib/ui/actions.ts +36 -0
- package/src/lib/ui/keyfollow.svelte.ts +48 -0
- package/src/lib/ui/overlay.ts +15 -0
- package/src/lib/ui/refdata.ts +355 -0
- package/src/lib/ui/scaleboard.ts +420 -0
- package/src/lib/ui/screens/DegreesRef.svelte +405 -0
- package/src/lib/ui/screens/DrillScreen.svelte +1155 -0
- package/src/lib/ui/screens/Explorer.svelte +245 -0
- package/src/lib/ui/screens/FamilyScreen.svelte +280 -0
- package/src/lib/ui/screens/Glossary.svelte +65 -0
- package/src/lib/ui/screens/IntervalRef.svelte +37 -0
- package/src/lib/ui/screens/LearnIndex.svelte +44 -0
- package/src/lib/ui/screens/MetronomeScreen.svelte +17 -0
- package/src/lib/ui/screens/PracticeHome.svelte +159 -0
- package/src/lib/ui/screens/RhythmRef.svelte +239 -0
- package/src/lib/ui/screens/ScalesScreen.svelte +210 -0
- package/src/lib/ui/screens/SessionDone.svelte +119 -0
- package/src/lib/ui/screens/SetupScreen.svelte +548 -0
- package/src/lib/ui/screens/StaffMap.svelte +230 -0
- package/src/lib/ui/screens/StatsScreen.svelte +478 -0
- package/src/lib/ui/screens/TapCalibration.svelte +186 -0
- package/src/lib/ui/tapfix.ts +93 -0
- package/src/lib/ui/types.ts +40 -0
- package/src/lib/util/list.ts +4 -0
- package/src/lib/util/random.ts +23 -0
- package/src/lib/util/text.ts +50 -0
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/* ---------------- pitch detection (MPM / NSDF) ----------------
|
|
2
|
+
McLeod's normalised square difference function: find the first lag whose
|
|
3
|
+
self-similarity is close to the best one, which is the period. Pure maths,
|
|
4
|
+
no audio objects, so it can be tested with synthetic tones. */
|
|
5
|
+
|
|
6
|
+
/** Keep every `factor`-th sample: a cheap way to lower the sample rate. */
|
|
7
|
+
export function decimate(samples: Float32Array, factor: number): Float32Array {
|
|
8
|
+
const length = Math.floor(samples.length / factor);
|
|
9
|
+
const out = new Float32Array(length);
|
|
10
|
+
for (let i = 0; i < length; i++) {
|
|
11
|
+
out[i] = samples[i * factor];
|
|
12
|
+
}
|
|
13
|
+
return out;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** The search band: a bass guitar's open B to well above a sung soprano's
|
|
17
|
+
lower range. */
|
|
18
|
+
const MIN_HZ = 36;
|
|
19
|
+
const MAX_HZ = 460;
|
|
20
|
+
/** Below this the strongest self-similarity is too weak to call a pitch. */
|
|
21
|
+
const MIN_CLARITY = 0.62;
|
|
22
|
+
/** The first peak within this share of the strongest one is the period —
|
|
23
|
+
picking the strongest outright would often land on a multiple of it. */
|
|
24
|
+
const PEAK_SHARE = 0.93;
|
|
25
|
+
|
|
26
|
+
/** The normalised square difference for every lag in range: 1 means the
|
|
27
|
+
signal repeats perfectly after that many samples. */
|
|
28
|
+
function nsdfCurve(buf: Float32Array, minLag: number, maxLag: number): Float32Array {
|
|
29
|
+
const curve = new Float32Array(maxLag + 1);
|
|
30
|
+
for (let lag = minLag; lag <= maxLag; lag++) {
|
|
31
|
+
let autocorrelation = 0;
|
|
32
|
+
let energy = 0;
|
|
33
|
+
const limit = buf.length - lag;
|
|
34
|
+
for (let i = 0; i < limit; i++) {
|
|
35
|
+
const a = buf[i];
|
|
36
|
+
const b = buf[i + lag];
|
|
37
|
+
autocorrelation += a * b;
|
|
38
|
+
energy += a * a + b * b;
|
|
39
|
+
}
|
|
40
|
+
curve[lag] = energy > 1e-12 ? (2 * autocorrelation) / energy : 0;
|
|
41
|
+
}
|
|
42
|
+
return curve;
|
|
43
|
+
}
|
|
44
|
+
/** The highest point of each stretch where the curve is above zero, as
|
|
45
|
+
[lag, value]. */
|
|
46
|
+
function positivePeaks(curve: Float32Array, minLag: number, maxLag: number): [number, number][] {
|
|
47
|
+
const peaks: [number, number][] = [];
|
|
48
|
+
let inside = false;
|
|
49
|
+
let bestLag = -1;
|
|
50
|
+
let bestValue = -1;
|
|
51
|
+
for (let lag = minLag; lag <= maxLag; lag++) {
|
|
52
|
+
const value = curve[lag];
|
|
53
|
+
if (!inside) {
|
|
54
|
+
if (value > 0) {
|
|
55
|
+
inside = true;
|
|
56
|
+
bestLag = lag;
|
|
57
|
+
bestValue = value;
|
|
58
|
+
}
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
if (value > bestValue) {
|
|
62
|
+
bestValue = value;
|
|
63
|
+
bestLag = lag;
|
|
64
|
+
}
|
|
65
|
+
if (value <= 0) {
|
|
66
|
+
if (bestLag > 0) {
|
|
67
|
+
peaks.push([bestLag, bestValue]);
|
|
68
|
+
}
|
|
69
|
+
inside = false;
|
|
70
|
+
bestLag = -1;
|
|
71
|
+
bestValue = -1;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
if (inside && bestLag > 0) {
|
|
75
|
+
peaks.push([bestLag, bestValue]);
|
|
76
|
+
}
|
|
77
|
+
return peaks;
|
|
78
|
+
}
|
|
79
|
+
/** Refine a peak to a fraction of a sample by fitting a parabola through it
|
|
80
|
+
and its two neighbours. */
|
|
81
|
+
function refinedLag(curve: Float32Array, lag: number): number {
|
|
82
|
+
const before = curve[lag - 1] || 0;
|
|
83
|
+
const at = curve[lag];
|
|
84
|
+
const after = curve[lag + 1] || 0;
|
|
85
|
+
const denominator = 2 * (2 * at - after - before);
|
|
86
|
+
const shift = denominator !== 0 ? (after - before) / denominator : 0;
|
|
87
|
+
return lag + shift;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Returns the fundamental in Hz and how clear it was, or null for no pitch.
|
|
91
|
+
The search band is 36–460 Hz: a bass guitar's open B to well above a
|
|
92
|
+
sung soprano's lower range. */
|
|
93
|
+
export function nsdfPitch(
|
|
94
|
+
buf: Float32Array,
|
|
95
|
+
sampleRate: number,
|
|
96
|
+
): { f: number; clarity: number } | null {
|
|
97
|
+
const maxLag = Math.min(buf.length - 2, Math.floor(sampleRate / MIN_HZ));
|
|
98
|
+
const minLag = Math.max(2, Math.floor(sampleRate / MAX_HZ));
|
|
99
|
+
if (maxLag <= minLag) {
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
const curve = nsdfCurve(buf, minLag, maxLag);
|
|
103
|
+
const peaks = positivePeaks(curve, minLag, maxLag);
|
|
104
|
+
if (!peaks.length) {
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
let strongest = 0;
|
|
108
|
+
for (const [, value] of peaks) {
|
|
109
|
+
if (value > strongest) {
|
|
110
|
+
strongest = value;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
if (strongest < MIN_CLARITY) {
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
const chosen = peaks.find(([, value]) => value >= PEAK_SHARE * strongest);
|
|
117
|
+
if (!chosen) {
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
const period = refinedLag(curve, chosen[0]);
|
|
121
|
+
if (period <= 0) {
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
let frequency = sampleRate / period;
|
|
125
|
+
/* just above the band: probably the octave of a note inside it */
|
|
126
|
+
if (frequency > MAX_HZ && frequency / 2 >= MIN_HZ) {
|
|
127
|
+
frequency = frequency / 2;
|
|
128
|
+
}
|
|
129
|
+
if (frequency < MIN_HZ || frequency > MAX_HZ) {
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
return { f: frequency, clarity: chosen[1] };
|
|
133
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { ac, acIfAny } from './context';
|
|
2
|
+
import { metClick } from './synth';
|
|
3
|
+
import type { Bar } from '../music/rhythm';
|
|
4
|
+
|
|
5
|
+
/* ---- playing a rhythm ---- */
|
|
6
|
+
const RPLAY: { on: boolean; timer: ReturnType<typeof setTimeout> | null; nodes: OscillatorNode[] } =
|
|
7
|
+
{ on: false, timer: null, nodes: [] };
|
|
8
|
+
|
|
9
|
+
export function rhStopPlay(): void {
|
|
10
|
+
RPLAY.on = false;
|
|
11
|
+
if (RPLAY.timer) {
|
|
12
|
+
clearTimeout(RPLAY.timer);
|
|
13
|
+
}
|
|
14
|
+
RPLAY.timer = null;
|
|
15
|
+
RPLAY.nodes.forEach((node) => {
|
|
16
|
+
try {
|
|
17
|
+
node.stop();
|
|
18
|
+
} catch {
|
|
19
|
+
/* already stopped */
|
|
20
|
+
}
|
|
21
|
+
});
|
|
22
|
+
RPLAY.nodes = [];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface RhPlayOpts {
|
|
26
|
+
countIn?: number;
|
|
27
|
+
/** click the beats under the bar (default true) */
|
|
28
|
+
beats?: boolean;
|
|
29
|
+
/** count in and click, but do not sound the rhythm — for tapping it yourself */
|
|
30
|
+
silent?: boolean;
|
|
31
|
+
onEnd?: () => void;
|
|
32
|
+
}
|
|
33
|
+
/** Lead time before the first click, so nothing is scheduled in the past. */
|
|
34
|
+
const LEAD_IN_S = 0.22;
|
|
35
|
+
/** How long after the bar ends `onEnd` fires. */
|
|
36
|
+
const AFTER_BAR_S = 0.35;
|
|
37
|
+
const SILENT = 0.0001;
|
|
38
|
+
|
|
39
|
+
/** Schedules the whole bar at once on the audio clock: a count-in of clicks,
|
|
40
|
+
then the rhythm itself, optionally with the beats ticking underneath.
|
|
41
|
+
Returns where the bar starts, seconds per sixteenth, and where it ends. */
|
|
42
|
+
export function rhPlay(
|
|
43
|
+
bar: Bar,
|
|
44
|
+
bpm: number,
|
|
45
|
+
opts: RhPlayOpts = {},
|
|
46
|
+
): { start: number; spu: number; end: number } | null {
|
|
47
|
+
const ctx = ac();
|
|
48
|
+
if (!ctx) {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
rhStopPlay();
|
|
52
|
+
const secondsPerBeat = 60 / bpm;
|
|
53
|
+
/* In simple time a click is a quarter: four sixteenths. In 6/8 the original
|
|
54
|
+
comment says the click is the dotted quarter, but the arithmetic makes a
|
|
55
|
+
click three sixteenths long, and only two beat clicks are laid under a
|
|
56
|
+
twelve-sixteenth bar. Kept as it was; noted in docs/ROADMAP.md as a
|
|
57
|
+
likely bug in compound-time playback. */
|
|
58
|
+
const secondsPerSixteenth = bar.cmp ? secondsPerBeat / 3 : secondsPerBeat / 4;
|
|
59
|
+
const countInStart = ctx.currentTime + LEAD_IN_S;
|
|
60
|
+
const countInBeats = opts.countIn == null ? 4 : opts.countIn;
|
|
61
|
+
for (let i = 0; i < countInBeats; i++) {
|
|
62
|
+
metClick(countInStart + i * secondsPerBeat, i === 0 ? 'accent' : 'beat');
|
|
63
|
+
}
|
|
64
|
+
const start = countInStart + countInBeats * secondsPerBeat;
|
|
65
|
+
if (opts.beats !== false) {
|
|
66
|
+
clickBeatsUnder(bar, start, secondsPerBeat, !opts.silent);
|
|
67
|
+
}
|
|
68
|
+
if (!opts.silent) {
|
|
69
|
+
attackTimes(bar, start, secondsPerSixteenth).forEach((time) => rhHit(time));
|
|
70
|
+
}
|
|
71
|
+
RPLAY.on = true;
|
|
72
|
+
const end = start + bar.u * secondsPerSixteenth + AFTER_BAR_S;
|
|
73
|
+
if (opts.onEnd) {
|
|
74
|
+
const onEnd = opts.onEnd;
|
|
75
|
+
RPLAY.timer = setTimeout(
|
|
76
|
+
() => {
|
|
77
|
+
RPLAY.on = false;
|
|
78
|
+
onEnd();
|
|
79
|
+
},
|
|
80
|
+
(end - ctx.currentTime) * 1000,
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
return { start, spu: secondsPerSixteenth, end };
|
|
84
|
+
}
|
|
85
|
+
/** While the rhythm is sounding the beats drop back to a quiet tick, with
|
|
86
|
+
only the downbeat kept up: the click is there as a ruler, and the thing
|
|
87
|
+
you are meant to be listening to is the rhythm. When you are tapping,
|
|
88
|
+
nothing else is sounding, so the beats stay at full strength. */
|
|
89
|
+
function clickBeatsUnder(bar: Bar, start: number, secondsPerBeat: number, quiet: boolean): void {
|
|
90
|
+
const beatCount = bar.cmp ? 2 : bar.ts[0];
|
|
91
|
+
for (let i = 0; i < beatCount; i++) {
|
|
92
|
+
let kind: 'accent' | 'beat' | 'sub';
|
|
93
|
+
if (i === 0) {
|
|
94
|
+
kind = quiet ? 'beat' : 'accent';
|
|
95
|
+
} else {
|
|
96
|
+
kind = quiet ? 'sub' : 'beat';
|
|
97
|
+
}
|
|
98
|
+
metClick(start + i * secondsPerBeat, kind);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
/** When each played note of the bar starts, in audio-clock seconds. */
|
|
102
|
+
function attackTimes(bar: Bar, start: number, secondsPerSixteenth: number): number[] {
|
|
103
|
+
const times: number[] = [];
|
|
104
|
+
let at = 0;
|
|
105
|
+
bar.ev.forEach((event, i) => {
|
|
106
|
+
if (!event.r && !(i > 0 && bar.ev[i - 1].tie)) {
|
|
107
|
+
times.push(start + at * secondsPerSixteenth);
|
|
108
|
+
}
|
|
109
|
+
at += event.u;
|
|
110
|
+
});
|
|
111
|
+
return times;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** The rhythm itself, as a woodblock knock. It has to sit clearly on top of
|
|
115
|
+
the click rather than under it — the click is the ruler, the knock is what
|
|
116
|
+
you are reading. An earlier version ran this through a band-pass centred
|
|
117
|
+
well above the note, which quietly threw most of the level away. */
|
|
118
|
+
function rhHit(time: number, soft?: boolean): void {
|
|
119
|
+
const ctx = acIfAny();
|
|
120
|
+
if (!ctx) {
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
/* Levels are set so a knock landing on a click still sums under full scale;
|
|
124
|
+
the balance comes from the beats dropping back, not from shouting. */
|
|
125
|
+
const level = soft ? 0.34 : 0.52;
|
|
126
|
+
// the knock: a triangle wave dropping fast in pitch
|
|
127
|
+
const knock = ctx.createOscillator();
|
|
128
|
+
const knockGain = ctx.createGain();
|
|
129
|
+
knock.type = 'triangle';
|
|
130
|
+
knock.frequency.setValueAtTime(880, time);
|
|
131
|
+
knock.frequency.exponentialRampToValueAtTime(300, time + 0.04);
|
|
132
|
+
knockGain.gain.setValueAtTime(SILENT, time);
|
|
133
|
+
knockGain.gain.exponentialRampToValueAtTime(level, time + 0.003);
|
|
134
|
+
knockGain.gain.exponentialRampToValueAtTime(SILENT, time + 0.2);
|
|
135
|
+
knock.connect(knockGain);
|
|
136
|
+
knockGain.connect(ctx.destination);
|
|
137
|
+
knock.start(time);
|
|
138
|
+
knock.stop(time + 0.24);
|
|
139
|
+
/* a short tick on top, so the attack is unmistakable even on a phone speaker */
|
|
140
|
+
const tick = ctx.createOscillator();
|
|
141
|
+
const tickGain = ctx.createGain();
|
|
142
|
+
tick.type = 'square';
|
|
143
|
+
tick.frequency.setValueAtTime(2300, time);
|
|
144
|
+
tickGain.gain.setValueAtTime(SILENT, time);
|
|
145
|
+
tickGain.gain.exponentialRampToValueAtTime(level * 0.29, time + 0.001);
|
|
146
|
+
tickGain.gain.exponentialRampToValueAtTime(SILENT, time + 0.03);
|
|
147
|
+
tick.connect(tickGain);
|
|
148
|
+
tickGain.connect(ctx.destination);
|
|
149
|
+
tick.start(time);
|
|
150
|
+
tick.stop(time + 0.05);
|
|
151
|
+
RPLAY.nodes.push(knock, tick);
|
|
152
|
+
}
|
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
import { ac, acIfAny, audioGate } from './context';
|
|
2
|
+
import { honmoon } from '../store/honmoon.svelte';
|
|
3
|
+
import { freqOfSounding } from '../music/notes';
|
|
4
|
+
import { STRINGS } from '../music/bass';
|
|
5
|
+
import { scaleDef, scaleShape } from '../music/scales';
|
|
6
|
+
import type { TimeSig } from '../music/rhythm';
|
|
7
|
+
|
|
8
|
+
/** Exponential ramps cannot start from or reach zero; this is "silent". */
|
|
9
|
+
const SILENT = 0.0001;
|
|
10
|
+
/** After a sound ends, how long its tail still reaches the microphone. */
|
|
11
|
+
const TAIL_S = 0.3;
|
|
12
|
+
|
|
13
|
+
/** Keep the pitch detector deaf until this sound has died away. */
|
|
14
|
+
function muteMicUntil(ctx: AudioContext, start: number, length: number): void {
|
|
15
|
+
audioGate.muteUntil = Math.max(
|
|
16
|
+
audioGate.muteUntil,
|
|
17
|
+
Date.now() + (start - ctx.currentTime + length + TAIL_S) * 1000,
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/* ---------------- bass pluck ---------------- */
|
|
22
|
+
const PLUCK_LENGTH_S = 1.25;
|
|
23
|
+
/** A plucked bass note at a sounding pitch. */
|
|
24
|
+
export function pluck(soundingMidi: number): void {
|
|
25
|
+
if (!honmoon.settings.sound || !isFinite(soundingMidi)) {
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
const ctx = ac();
|
|
29
|
+
if (!ctx) {
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
const freq = freqOfSounding(soundingMidi);
|
|
33
|
+
const now = ctx.currentTime;
|
|
34
|
+
const length = PLUCK_LENGTH_S;
|
|
35
|
+
audioGate.muteUntil = Date.now() + (length + 0.35) * 1000;
|
|
36
|
+
// the string: a triangle wave, and a quieter, shorter octave above it
|
|
37
|
+
const body = ctx.createOscillator();
|
|
38
|
+
body.type = 'triangle';
|
|
39
|
+
body.frequency.value = freq;
|
|
40
|
+
const octave = ctx.createOscillator();
|
|
41
|
+
octave.type = 'sine';
|
|
42
|
+
octave.frequency.value = freq * 2;
|
|
43
|
+
const bodyGain = ctx.createGain();
|
|
44
|
+
const octaveGain = ctx.createGain();
|
|
45
|
+
// the tone darkens as the note decays
|
|
46
|
+
const lowpass = ctx.createBiquadFilter();
|
|
47
|
+
lowpass.type = 'lowpass';
|
|
48
|
+
lowpass.frequency.setValueAtTime(Math.min(freq * 10, 6000), now);
|
|
49
|
+
lowpass.frequency.exponentialRampToValueAtTime(Math.max(freq * 2, 110), now + length);
|
|
50
|
+
bodyGain.gain.setValueAtTime(SILENT, now);
|
|
51
|
+
bodyGain.gain.exponentialRampToValueAtTime(0.32, now + 0.01);
|
|
52
|
+
bodyGain.gain.exponentialRampToValueAtTime(SILENT, now + length);
|
|
53
|
+
octaveGain.gain.setValueAtTime(SILENT, now);
|
|
54
|
+
octaveGain.gain.exponentialRampToValueAtTime(0.07, now + 0.01);
|
|
55
|
+
octaveGain.gain.exponentialRampToValueAtTime(SILENT, now + length * 0.55);
|
|
56
|
+
body.connect(bodyGain);
|
|
57
|
+
octave.connect(octaveGain);
|
|
58
|
+
bodyGain.connect(lowpass);
|
|
59
|
+
octaveGain.connect(lowpass);
|
|
60
|
+
lowpass.connect(ctx.destination);
|
|
61
|
+
body.start(now);
|
|
62
|
+
octave.start(now);
|
|
63
|
+
body.stop(now + length + 0.1);
|
|
64
|
+
octave.stop(now + length + 0.1);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/* ---------------- piano ----------------
|
|
68
|
+
A struck string decays towards silence at its own rate rather than being
|
|
69
|
+
ramped to zero on a deadline, so the envelope is setTargetAtTime with a
|
|
70
|
+
time constant. Low notes ring far longer than high ones, and the upper
|
|
71
|
+
partials fade first, which is what makes a piano mellow as it sustains.
|
|
72
|
+
`dur` is how long the key is held down: at the end the damper lands. */
|
|
73
|
+
|
|
74
|
+
/** How long a note at this pitch takes to fade: ~6 s at A2, halving every
|
|
75
|
+
26 semitones up, kept between 1.2 and 9 seconds. */
|
|
76
|
+
function pianoTau(midi: number): number {
|
|
77
|
+
return Math.max(1.2, Math.min(9, 6.2 * Math.pow(0.5, (midi - 45) / 26)));
|
|
78
|
+
}
|
|
79
|
+
/** Each partial: harmonic number, slight sharpening (a real string's
|
|
80
|
+
inharmonicity), and loudness relative to the fundamental. */
|
|
81
|
+
const PIANO_PARTIALS = [
|
|
82
|
+
[1, 1.0, 1],
|
|
83
|
+
[2, 1.0013, 0.34],
|
|
84
|
+
[3, 1.0035, 0.13],
|
|
85
|
+
[4, 1.007, 0.055],
|
|
86
|
+
[5, 1.011, 0.025],
|
|
87
|
+
[6, 1.016, 0.012],
|
|
88
|
+
] as const;
|
|
89
|
+
const PIANO_LOUDNESS = 0.23;
|
|
90
|
+
/** Partials above this would only alias. */
|
|
91
|
+
const HIGHEST_PARTIAL_HZ = 15500;
|
|
92
|
+
const DAMPER_TAU = 0.06;
|
|
93
|
+
|
|
94
|
+
export interface PianoNote {
|
|
95
|
+
damp(): void;
|
|
96
|
+
}
|
|
97
|
+
export function piano(
|
|
98
|
+
soundingMidi: number,
|
|
99
|
+
when?: number | null,
|
|
100
|
+
dur?: number,
|
|
101
|
+
vel?: number | null,
|
|
102
|
+
): PianoNote | null {
|
|
103
|
+
const ctx = ac();
|
|
104
|
+
if (!ctx || !isFinite(soundingMidi)) {
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
const start = when == null ? ctx.currentTime + 0.02 : when;
|
|
108
|
+
const freq = freqOfSounding(soundingMidi);
|
|
109
|
+
const held = dur || 1.6;
|
|
110
|
+
const velocity = vel == null ? 1 : vel;
|
|
111
|
+
if (freq < 20 || freq > 4500) {
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
const tau = pianoTau(soundingMidi);
|
|
115
|
+
const out = ctx.createGain();
|
|
116
|
+
out.gain.value = 1;
|
|
117
|
+
out.connect(ctx.destination);
|
|
118
|
+
const gains: GainNode[] = [];
|
|
119
|
+
const oscillators: OscillatorNode[] = [];
|
|
120
|
+
for (const [harmonic, stretch, relative] of PIANO_PARTIALS) {
|
|
121
|
+
if (freq * harmonic > HIGHEST_PARTIAL_HZ) {
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
const osc = ctx.createOscillator();
|
|
125
|
+
const gain = ctx.createGain();
|
|
126
|
+
osc.type = 'sine';
|
|
127
|
+
osc.frequency.setValueAtTime(freq * harmonic * stretch, start);
|
|
128
|
+
const level = PIANO_LOUDNESS * velocity * relative;
|
|
129
|
+
// higher partials fade faster
|
|
130
|
+
const partialTau = tau / (1 + 0.6 * (harmonic - 1));
|
|
131
|
+
gain.gain.setValueAtTime(SILENT, start);
|
|
132
|
+
gain.gain.exponentialRampToValueAtTime(Math.max(0.0002, level), start + 0.005);
|
|
133
|
+
gain.gain.setTargetAtTime(0, start + 0.006, partialTau);
|
|
134
|
+
gain.gain.setTargetAtTime(0, start + held, DAMPER_TAU);
|
|
135
|
+
osc.connect(gain);
|
|
136
|
+
gain.connect(out);
|
|
137
|
+
osc.start(start);
|
|
138
|
+
osc.stop(start + held + 0.5);
|
|
139
|
+
gains.push(gain);
|
|
140
|
+
oscillators.push(osc);
|
|
141
|
+
}
|
|
142
|
+
muteMicUntil(ctx, start, Math.min(held, 4));
|
|
143
|
+
return {
|
|
144
|
+
damp() {
|
|
145
|
+
const now = ctx.currentTime;
|
|
146
|
+
gains.forEach((gain) => {
|
|
147
|
+
try {
|
|
148
|
+
if (gain.gain.cancelAndHoldAtTime) {
|
|
149
|
+
gain.gain.cancelAndHoldAtTime(now);
|
|
150
|
+
} else {
|
|
151
|
+
const current = gain.gain.value;
|
|
152
|
+
gain.gain.cancelScheduledValues(now);
|
|
153
|
+
gain.gain.setValueAtTime(current, now);
|
|
154
|
+
}
|
|
155
|
+
gain.gain.setTargetAtTime(0, now, DAMPER_TAU);
|
|
156
|
+
} catch {
|
|
157
|
+
/* already stopped */
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
setTimeout(
|
|
161
|
+
() =>
|
|
162
|
+
oscillators.forEach((osc) => {
|
|
163
|
+
try {
|
|
164
|
+
osc.stop();
|
|
165
|
+
} catch {
|
|
166
|
+
/* already stopped */
|
|
167
|
+
}
|
|
168
|
+
}),
|
|
169
|
+
500,
|
|
170
|
+
);
|
|
171
|
+
},
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
/** The notes of a chord, a hair apart, like fingers landing. */
|
|
175
|
+
const CHORD_SPREAD_S = 0.012;
|
|
176
|
+
export function pianoChord(
|
|
177
|
+
midis: number[],
|
|
178
|
+
when?: number | null,
|
|
179
|
+
dur?: number,
|
|
180
|
+
vel?: number | null,
|
|
181
|
+
): void {
|
|
182
|
+
midis.forEach((midi, i) => {
|
|
183
|
+
piano(midi, when == null ? null : when + i * CHORD_SPREAD_S, dur, vel == null ? 0.7 : vel);
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/* One held note, pedal down, until it is stopped. */
|
|
188
|
+
const HELD_NOTE_S = 16;
|
|
189
|
+
const HOLD: { h: PianoNote | null; midi: number | null } = { h: null, midi: null };
|
|
190
|
+
export function holdStop(): void {
|
|
191
|
+
if (HOLD.h) {
|
|
192
|
+
try {
|
|
193
|
+
HOLD.h.damp();
|
|
194
|
+
} catch {
|
|
195
|
+
/* gone */
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
HOLD.h = null;
|
|
199
|
+
HOLD.midi = null;
|
|
200
|
+
}
|
|
201
|
+
export function holdNote(midi: number): void {
|
|
202
|
+
holdStop();
|
|
203
|
+
HOLD.h = piano(midi, null, HELD_NOTE_S, 1);
|
|
204
|
+
HOLD.midi = midi;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/* ---------------- the siren ----------------
|
|
208
|
+
A raw sawtooth swept through a fixed filter is a machine, not a singer: the
|
|
209
|
+
harmonics all rise together and nothing about it breathes. A voice on "ng"
|
|
210
|
+
is a handful of partials falling away steeply, two soft resonances that stay
|
|
211
|
+
put while the pitch moves under them, and a vibrato that arrives after the
|
|
212
|
+
note has settled rather than being switched on with it. That is what this
|
|
213
|
+
builds, and why it is one oscillator over a path of waypoints instead of two
|
|
214
|
+
separate slides butted together — a siren does not re-attack at the top. */
|
|
215
|
+
let voiceWaveCache: PeriodicWave | null = null;
|
|
216
|
+
/** Amplitude of each harmonic of the voice tone, falling away steeply. */
|
|
217
|
+
const VOICE_HARMONICS = [0, 1, 0.52, 0.3, 0.16, 0.085, 0.045, 0.024, 0.012, 0.006];
|
|
218
|
+
function voiceWave(ctx: AudioContext): PeriodicWave {
|
|
219
|
+
if (voiceWaveCache) {
|
|
220
|
+
return voiceWaveCache;
|
|
221
|
+
}
|
|
222
|
+
const real = new Float32Array(VOICE_HARMONICS.length);
|
|
223
|
+
const imaginary = Float32Array.from(VOICE_HARMONICS);
|
|
224
|
+
voiceWaveCache = ctx.createPeriodicWave(real, imaginary, { disableNormalization: false });
|
|
225
|
+
return voiceWaveCache;
|
|
226
|
+
}
|
|
227
|
+
const VIBRATO_HZ = 5.1;
|
|
228
|
+
const VIBRATO_CENTS = 13;
|
|
229
|
+
/** The two resonances of an "ng"/"ah"-like voice, held still. */
|
|
230
|
+
const FORMANTS = [
|
|
231
|
+
{ hz: 430, q: 1.1, gain: 1 },
|
|
232
|
+
{ hz: 1180, q: 1.5, gain: 0.45 },
|
|
233
|
+
];
|
|
234
|
+
const SIREN_PEAK = 0.15;
|
|
235
|
+
|
|
236
|
+
/** A voice sliding smoothly through the given notes, `dur` seconds in all. */
|
|
237
|
+
export function slidePath(midis: number[], when: number | null, dur: number): void {
|
|
238
|
+
const ctx = ac();
|
|
239
|
+
if (!ctx || !midis || midis.length < 2) {
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
const start = when == null ? ctx.currentTime + 0.02 : when;
|
|
243
|
+
const legLength = dur / (midis.length - 1);
|
|
244
|
+
const freqs = midis.map(freqOfSounding);
|
|
245
|
+
if (!freqs.every((freq) => freq > 20 && freq < 4000)) {
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const osc = ctx.createOscillator();
|
|
250
|
+
osc.setPeriodicWave(voiceWave(ctx));
|
|
251
|
+
osc.frequency.setValueAtTime(freqs[0], start);
|
|
252
|
+
/* exponential in frequency is linear in semitones, so the sweep sounds even */
|
|
253
|
+
for (let i = 1; i < freqs.length; i++) {
|
|
254
|
+
osc.frequency.exponentialRampToValueAtTime(freqs[i], start + i * legLength);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// vibrato fades in once the note has settled
|
|
258
|
+
const lfo = ctx.createOscillator();
|
|
259
|
+
const vibratoDepth = ctx.createGain();
|
|
260
|
+
lfo.type = 'sine';
|
|
261
|
+
lfo.frequency.value = VIBRATO_HZ;
|
|
262
|
+
vibratoDepth.gain.setValueAtTime(0, start);
|
|
263
|
+
vibratoDepth.gain.linearRampToValueAtTime(VIBRATO_CENTS, start + Math.min(1.1, dur * 0.45));
|
|
264
|
+
lfo.connect(vibratoDepth);
|
|
265
|
+
vibratoDepth.connect(osc.detune);
|
|
266
|
+
|
|
267
|
+
/* two formants, fixed in place while the pitch moves through them, plus a
|
|
268
|
+
little of the dry tone, all through a lowpass that follows the pitch */
|
|
269
|
+
const lowpass = ctx.createBiquadFilter();
|
|
270
|
+
lowpass.type = 'lowpass';
|
|
271
|
+
lowpass.Q.value = 0.7;
|
|
272
|
+
for (const formant of FORMANTS) {
|
|
273
|
+
const band = ctx.createBiquadFilter();
|
|
274
|
+
band.type = 'bandpass';
|
|
275
|
+
band.frequency.value = formant.hz;
|
|
276
|
+
band.Q.value = formant.q;
|
|
277
|
+
const level = ctx.createGain();
|
|
278
|
+
level.gain.value = formant.gain;
|
|
279
|
+
osc.connect(band);
|
|
280
|
+
band.connect(level);
|
|
281
|
+
level.connect(lowpass);
|
|
282
|
+
}
|
|
283
|
+
const dry = ctx.createGain();
|
|
284
|
+
dry.gain.value = 0.22;
|
|
285
|
+
osc.connect(dry);
|
|
286
|
+
dry.connect(lowpass);
|
|
287
|
+
const brightness = (freq: number) => Math.max(900, Math.min(5000, freq * 6 + 600));
|
|
288
|
+
lowpass.frequency.setValueAtTime(brightness(freqs[0]), start);
|
|
289
|
+
for (let i = 1; i < freqs.length; i++) {
|
|
290
|
+
lowpass.frequency.exponentialRampToValueAtTime(brightness(freqs[i]), start + i * legLength);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const envelope = ctx.createGain();
|
|
294
|
+
const attack = Math.min(0.26, dur * 0.22);
|
|
295
|
+
const release = Math.min(0.3, dur * 0.25);
|
|
296
|
+
envelope.gain.setValueAtTime(SILENT, start);
|
|
297
|
+
envelope.gain.exponentialRampToValueAtTime(SIREN_PEAK, start + attack);
|
|
298
|
+
envelope.gain.setValueAtTime(SIREN_PEAK, start + Math.max(attack + 0.01, dur - release));
|
|
299
|
+
envelope.gain.exponentialRampToValueAtTime(SILENT, start + dur);
|
|
300
|
+
|
|
301
|
+
lowpass.connect(envelope);
|
|
302
|
+
envelope.connect(ctx.destination);
|
|
303
|
+
osc.start(start);
|
|
304
|
+
osc.stop(start + dur + 0.1);
|
|
305
|
+
lfo.start(start);
|
|
306
|
+
lfo.stop(start + dur + 0.1);
|
|
307
|
+
muteMicUntil(ctx, start, dur);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/* ---------------- metronome click ---------------- */
|
|
311
|
+
const CLICKS = {
|
|
312
|
+
accent: { hz: 1600, level: 0.5 },
|
|
313
|
+
beat: { hz: 1050, level: 0.34 },
|
|
314
|
+
sub: { hz: 820, level: 0.12 },
|
|
315
|
+
};
|
|
316
|
+
/** A metronome click on the audio clock. */
|
|
317
|
+
export function metClick(time: number, kind: 'accent' | 'beat' | 'sub'): void {
|
|
318
|
+
const ctx = acIfAny();
|
|
319
|
+
if (!ctx) {
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
const osc = ctx.createOscillator();
|
|
323
|
+
const gain = ctx.createGain();
|
|
324
|
+
osc.type = 'square';
|
|
325
|
+
osc.frequency.setValueAtTime(CLICKS[kind].hz, time);
|
|
326
|
+
gain.gain.setValueAtTime(SILENT, time);
|
|
327
|
+
gain.gain.exponentialRampToValueAtTime(CLICKS[kind].level, time + 0.002);
|
|
328
|
+
gain.gain.exponentialRampToValueAtTime(SILENT, time + 0.045);
|
|
329
|
+
osc.connect(gain);
|
|
330
|
+
gain.connect(ctx.destination);
|
|
331
|
+
osc.start(time);
|
|
332
|
+
osc.stop(time + 0.06);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/** Two notes, one after the other — or together when harmonic. */
|
|
336
|
+
export function playInterval(root: number, semis: number, harmonic?: boolean): void {
|
|
337
|
+
if (harmonic) {
|
|
338
|
+
pianoChord([root, root + semis], null, 1.8, 0.8);
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
const ctx = ac();
|
|
342
|
+
if (!ctx) {
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
const start = ctx.currentTime + 0.06;
|
|
346
|
+
piano(root, start, 1.0);
|
|
347
|
+
piano(root + semis, start + 0.62, 1.6);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/** Time between notes when a scale is plucked up the neck. */
|
|
351
|
+
const SCALE_NOTE_MS = 330;
|
|
352
|
+
/** A scale as a bass plays it: up the box from the given string. */
|
|
353
|
+
export function playScale(sid: string, root: number, start: number): void {
|
|
354
|
+
if (!honmoon.settings.sound) {
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
const shape = scaleShape(sid, root, start, 1);
|
|
358
|
+
if (!ac()) {
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
shape.pos.forEach((pos, i) => {
|
|
362
|
+
// written pitch to sounding pitch: a bass sounds an octave lower
|
|
363
|
+
setTimeout(() => pluck(STRINGS[pos.s].open + pos.f - 12), i * SCALE_NOTE_MS);
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
/** The scale sounded plainly, straight up and landing on the octave, so what
|
|
367
|
+
you are judging is the pattern of steps and nothing else — no shape on the
|
|
368
|
+
neck, no rhythm to hang on to. */
|
|
369
|
+
export function playScaleEar(sid: string, root: number): void {
|
|
370
|
+
if (!honmoon.settings.sound) {
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
const scale = scaleDef(sid);
|
|
374
|
+
const ctx = ac();
|
|
375
|
+
if (!scale || !ctx) {
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
const noteGap = 0.3;
|
|
379
|
+
const start = ctx.currentTime + 0.06;
|
|
380
|
+
scale.iv.concat([12]).forEach((semis, i) => {
|
|
381
|
+
piano(root + semis, start + i * noteGap, noteGap * 1.6, 0.85);
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/** Two bars of the bare pulse. A time signature is a thing you feel before it
|
|
386
|
+
is a thing you read, and the difference between three and four is obvious
|
|
387
|
+
the moment you hear where the accent lands. */
|
|
388
|
+
export function playMeter(ts: TimeSig): void {
|
|
389
|
+
const ctx = ac();
|
|
390
|
+
if (!ctx) {
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
const [count, unit] = ts;
|
|
394
|
+
const compound = unit === 8 && count % 3 === 0 && count > 3;
|
|
395
|
+
const bpm = compound ? 168 : 88;
|
|
396
|
+
const secondsPerClick = 60 / bpm;
|
|
397
|
+
const start = ctx.currentTime + 0.2;
|
|
398
|
+
for (let bar = 0; bar < 2; bar++) {
|
|
399
|
+
for (let i = 0; i < count; i++) {
|
|
400
|
+
let kind: 'accent' | 'beat' | 'sub' = 'beat';
|
|
401
|
+
if (i === 0) {
|
|
402
|
+
kind = 'accent';
|
|
403
|
+
} else if (compound && i % 3 !== 0) {
|
|
404
|
+
kind = 'sub';
|
|
405
|
+
}
|
|
406
|
+
metClick(start + (bar * count + i) * secondsPerClick, kind);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
}
|