@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.
Files changed (82) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +81 -2
  3. package/package.json +58 -4
  4. package/src/lib/audio/context.ts +29 -0
  5. package/src/lib/audio/metronome.svelte.ts +146 -0
  6. package/src/lib/audio/mic.ts +254 -0
  7. package/src/lib/audio/pitch.ts +133 -0
  8. package/src/lib/audio/rhythmplay.ts +152 -0
  9. package/src/lib/audio/synth.ts +409 -0
  10. package/src/lib/audio/voiceplay.ts +60 -0
  11. package/src/lib/drills/defs.ts +459 -0
  12. package/src/lib/drills/questions.ts +920 -0
  13. package/src/lib/drills/registry.svelte.ts +167 -0
  14. package/src/lib/drills/runtime.svelte.ts +629 -0
  15. package/src/lib/drills/verdict.ts +241 -0
  16. package/src/lib/index.ts +117 -0
  17. package/src/lib/learn/describe.ts +72 -0
  18. package/src/lib/learn/pools.ts +44 -0
  19. package/src/lib/learn/progress.ts +100 -0
  20. package/src/lib/learn/session.ts +304 -0
  21. package/src/lib/music/bass.ts +139 -0
  22. package/src/lib/music/clefs.ts +218 -0
  23. package/src/lib/music/intervals.ts +94 -0
  24. package/src/lib/music/notes.ts +133 -0
  25. package/src/lib/music/rhythm.ts +624 -0
  26. package/src/lib/music/scales.ts +560 -0
  27. package/src/lib/music/voice.ts +227 -0
  28. package/src/lib/music/warmups.ts +318 -0
  29. package/src/lib/nav.svelte.ts +53 -0
  30. package/src/lib/notation/fretboard.ts +187 -0
  31. package/src/lib/notation/glyphdata.ts +101 -0
  32. package/src/lib/notation/glyphs.ts +71 -0
  33. package/src/lib/notation/icons.ts +12 -0
  34. package/src/lib/notation/keyboard.ts +166 -0
  35. package/src/lib/notation/rhythmsvg.ts +341 -0
  36. package/src/lib/notation/staff.ts +288 -0
  37. package/src/lib/notation/svg.ts +37 -0
  38. package/src/lib/notation/tab.ts +80 -0
  39. package/src/lib/store/honmoon.svelte.ts +329 -0
  40. package/src/lib/store/normalize.ts +23 -0
  41. package/src/lib/store/sync.ts +43 -0
  42. package/src/lib/store/types.ts +101 -0
  43. package/src/lib/styles/honmoon.css +2064 -0
  44. package/src/lib/ui/AccRow.svelte +23 -0
  45. package/src/lib/ui/BackBar.svelte +23 -0
  46. package/src/lib/ui/BassRow.svelte +30 -0
  47. package/src/lib/ui/Chips.svelte +23 -0
  48. package/src/lib/ui/DataCard.svelte +66 -0
  49. package/src/lib/ui/Fretboard.svelte +71 -0
  50. package/src/lib/ui/Keyboard.svelte +54 -0
  51. package/src/lib/ui/MetronomePanel.svelte +101 -0
  52. package/src/lib/ui/RefBar.svelte +28 -0
  53. package/src/lib/ui/RhythmBar.svelte +9 -0
  54. package/src/lib/ui/Shell.svelte +96 -0
  55. package/src/lib/ui/StaffRun.svelte +46 -0
  56. package/src/lib/ui/Stat.svelte +13 -0
  57. package/src/lib/ui/actions.ts +36 -0
  58. package/src/lib/ui/keyfollow.svelte.ts +48 -0
  59. package/src/lib/ui/overlay.ts +15 -0
  60. package/src/lib/ui/refdata.ts +355 -0
  61. package/src/lib/ui/scaleboard.ts +420 -0
  62. package/src/lib/ui/screens/DegreesRef.svelte +405 -0
  63. package/src/lib/ui/screens/DrillScreen.svelte +1155 -0
  64. package/src/lib/ui/screens/Explorer.svelte +245 -0
  65. package/src/lib/ui/screens/FamilyScreen.svelte +280 -0
  66. package/src/lib/ui/screens/Glossary.svelte +65 -0
  67. package/src/lib/ui/screens/IntervalRef.svelte +37 -0
  68. package/src/lib/ui/screens/LearnIndex.svelte +44 -0
  69. package/src/lib/ui/screens/MetronomeScreen.svelte +17 -0
  70. package/src/lib/ui/screens/PracticeHome.svelte +159 -0
  71. package/src/lib/ui/screens/RhythmRef.svelte +239 -0
  72. package/src/lib/ui/screens/ScalesScreen.svelte +210 -0
  73. package/src/lib/ui/screens/SessionDone.svelte +119 -0
  74. package/src/lib/ui/screens/SetupScreen.svelte +548 -0
  75. package/src/lib/ui/screens/StaffMap.svelte +230 -0
  76. package/src/lib/ui/screens/StatsScreen.svelte +478 -0
  77. package/src/lib/ui/screens/TapCalibration.svelte +186 -0
  78. package/src/lib/ui/tapfix.ts +93 -0
  79. package/src/lib/ui/types.ts +40 -0
  80. package/src/lib/util/list.ts +4 -0
  81. package/src/lib/util/random.ts +23 -0
  82. package/src/lib/util/text.ts +50 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 GreenBlackSky
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,82 @@
1
- # Temporary Holding Version
1
+ # Honmoon
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ The engine behind three apps: **Mira** (bass), **Rumi** (voice) and **Zoey**
4
+ (theory) — the music model, spaced repetition, audio, notation drawing, and
5
+ the screens the apps share. The names are a nod to HUNTR/X from *KPop Demon
6
+ Hunters*: three members, and the Honmoon that all three of them hold up.
7
+ It is a library, not an app — you never open it on its own.
8
+
9
+ It started life as Fretboard Fluency (one 6,000-line HTML file) and was
10
+ carried over to TypeScript and Svelte.
11
+
12
+ ## What's where
13
+
14
+ ```
15
+ src/lib/
16
+ music/ notes and spelling, scales and their fingerings, clefs, intervals,
17
+ rhythm, voice warm-up data, bass warm-up exercise generators
18
+ notation/ drawing as SVG: staff, fretboard, keyboard, rhythm, tab, glyphs
19
+ audio/ the synth voices, metronome, rhythm playback, mic pitch detection
20
+ store/ what is remembered (types, saving, Firebase placeholder)
21
+ learn/ card pools, sampling, sessions, progress summaries
22
+ drills/ the 39 drills: definitions, question makers, answering, verdicts
23
+ ui/ Svelte components: the app shell, fretboard/keyboard/staff widgets,
24
+ and the shared screens (Practice, Drill, Stats, Setup, references…)
25
+ styles/ honmoon.css — the one stylesheet all three apps use, and the
26
+ palette: a shared night base, fixed progress colours
27
+ (--good / --mid / --bad) and an accent each app sets itself
28
+ nav.svelte.ts which screen is showing, and go('name') to change it
29
+ index.ts everything the apps import
30
+ tests/ *.test.ts files, run by Vitest (none yet)
31
+ ```
32
+
33
+ ## Working on it
34
+
35
+ Honmoon lives in `apps/huntrix/` next to the three apps, and is installed
36
+ together with them: run `npm install` once in the `apps` folder (see
37
+ `apps/README.md`). Then, in this folder:
38
+
39
+ ```
40
+ npm test the tests in tests/ (passes when there are none)
41
+ npm run check TypeScript and Svelte type checking
42
+ npm run lint code problems (ESLint); lint:fix repairs what it can
43
+ npm run format tidy the layout of every file (Prettier)
44
+ ```
45
+
46
+ ## How the apps get it
47
+
48
+ The workspace links
49
+ `@greenblacksky/honmoon` in its `node_modules` straight to this folder, and
50
+ `package.json` points the apps at the TypeScript and Svelte source in
51
+ `src/lib` — so there is nothing to build or release: save a file here and
52
+ every running app reloads with the change.
53
+
54
+ The version number still matters. Each app asks for a range
55
+ (`"@greenblacksky/honmoon": "^0.4.0"`), and the workspace only links this folder
56
+ when its version fits. For a change the apps must adapt to, bump it
57
+ (`npm version minor`), update the range in each app, and once the repos
58
+ are on GitHub tag the commit, so you can always tell which engine an app
59
+ was tested with.
60
+
61
+ Building an app outside the workspace (for example, automatic
62
+ builds on GitHub) needs Honmoon published to a package registry. How to do
63
+ that (publicly on npmjs.com, or privately on GitHub Packages), and the GitHub Actions pipeline for
64
+ the apps, is in [docs/PUBLISHING.md](docs/PUBLISHING.md).
65
+
66
+ ## Tests
67
+
68
+ The parity tests that checked the port against the original app were
69
+ removed once the migration was done. New tests go in `tests/` as
70
+ `something.test.ts` and run with `npm test`.
71
+
72
+ ## Saving and Firebase
73
+
74
+ Progress is saved in the browser (`localStorage`, one key per app). The
75
+ format is the original app's, so a file exported from either can be imported
76
+ into the other. `src/lib/store/sync.ts` holds `FirebaseSync`, a placeholder
77
+ with the shape the real thing will have; the plan for it is written at the
78
+ top of that file.
79
+
80
+ ## Licence
81
+
82
+ MIT — see [LICENSE](LICENSE).
package/package.json CHANGED
@@ -1,6 +1,60 @@
1
1
  {
2
2
  "name": "@greenblacksky/honmoon",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.4.0",
4
+ "description": "Honmoon: the engine under Mira, Rumi and Zoey — the music model, spaced repetition, audio, notation drawing and the shared Svelte screens.",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/GreenBlackSky/honmoon.git"
9
+ },
10
+ "publishConfig": {
11
+ "access": "public"
12
+ },
13
+ "type": "module",
14
+ "svelte": "./src/lib/index.ts",
15
+ "types": "./src/lib/index.ts",
16
+ "exports": {
17
+ ".": {
18
+ "types": "./src/lib/index.ts",
19
+ "svelte": "./src/lib/index.ts",
20
+ "default": "./src/lib/index.ts"
21
+ },
22
+ "./styles.css": "./src/lib/styles/honmoon.css"
23
+ },
24
+ "files": [
25
+ "src/lib"
26
+ ],
27
+ "sideEffects": [
28
+ "**/*.css"
29
+ ],
30
+ "scripts": {
31
+ "check": "svelte-check --tsconfig ./tsconfig.json",
32
+ "test": "vitest run --passWithNoTests",
33
+ "package": "svelte-package -i src/lib -o dist",
34
+ "format": "prettier --write .",
35
+ "format:check": "prettier --check .",
36
+ "lint": "eslint .",
37
+ "lint:fix": "eslint --fix ."
38
+ },
39
+ "peerDependencies": {
40
+ "svelte": "^5.57.0"
41
+ },
42
+ "devDependencies": {
43
+ "@eslint/js": "10.0.1",
44
+ "@sveltejs/package": "3.0.1",
45
+ "@sveltejs/vite-plugin-svelte": "7.3.1",
46
+ "@types/node": "^22.20.5",
47
+ "eslint": "10.13.0",
48
+ "eslint-config-prettier": "10.1.8",
49
+ "eslint-plugin-svelte": "3.23.1",
50
+ "globals": "17.13.0",
51
+ "prettier": "3.9.10",
52
+ "prettier-plugin-svelte": "4.1.1",
53
+ "svelte": "5.57.2",
54
+ "svelte-check": "4.7.7",
55
+ "typescript": "6.0.3",
56
+ "typescript-eslint": "8.72.0",
57
+ "vite": "8.3.4",
58
+ "vitest": "5.0.5"
59
+ }
60
+ }
@@ -0,0 +1,29 @@
1
+ /* One AudioContext for the whole app, created on first use — browsers only
2
+ allow sound after a user gesture, and every first use here is one. */
3
+ let context: AudioContext | null = null;
4
+
5
+ export function ac(): AudioContext | null {
6
+ if (!context) {
7
+ try {
8
+ const AudioContextClass =
9
+ window.AudioContext ||
10
+ (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext;
11
+ context = new AudioContextClass();
12
+ } catch {
13
+ context = null;
14
+ }
15
+ }
16
+ if (context && context.state === 'suspended') {
17
+ context.resume();
18
+ }
19
+ return context;
20
+ }
21
+ /** The context if it already exists, without creating one. */
22
+ export function acIfAny(): AudioContext | null {
23
+ return context;
24
+ }
25
+
26
+ /** While the app itself is making sound, the pitch detector would hear it and
27
+ grade it as your playing. Every sound pushes this out to when it will have
28
+ died away, and the detector ignores everything before then. */
29
+ export const audioGate = { muteUntil: 0 };
@@ -0,0 +1,146 @@
1
+ import { ac, acIfAny } from './context';
2
+ import { metClick } from './synth';
3
+
4
+ /* ---------------- metronome ----------------
5
+ Timers drift; the audio clock does not. A short polling loop schedules
6
+ clicks a fraction of a second ahead on the Web Audio timeline, so the beats
7
+ are placed by the sound hardware rather than by JavaScript. The reactive
8
+ fields drive the screen; the rest is bookkeeping. */
9
+ export const met = $state({
10
+ on: false,
11
+ bpm: 72,
12
+ beats: 4,
13
+ sub: 1,
14
+ subClick: false,
15
+ /** the beat light: '' off, 'hit' on a beat, 'hit one' on the downbeat */
16
+ flash: '',
17
+ /** "2 / 4" while running */
18
+ beatLabel: '—',
19
+ });
20
+
21
+ /** How far ahead clicks are put on the audio clock, and how often the
22
+ scheduler looks. */
23
+ const LOOKAHEAD_S = 0.14;
24
+ const SCHEDULER_INTERVAL_MS = 25;
25
+ const FLASH_MS = 90;
26
+ export const BPM_MIN = 40;
27
+ export const BPM_MAX = 200;
28
+ /** Tap tempo: taps older than this are forgotten, and an average gap outside
29
+ 250–1600 ms (240 down to about 38 bpm) is not taken as a beat. */
30
+ const TAP_MEMORY_MS = 2500;
31
+ const TAP_GAP_MIN_MS = 250;
32
+ const TAP_GAP_MAX_MS = 1600;
33
+
34
+ let timer: ReturnType<typeof setInterval> | null = null;
35
+ /** Clicks scheduled so far (subdivisions included), and when the next falls. */
36
+ let tick = 0;
37
+ let nextClickTime = 0;
38
+ /** Beats scheduled but not yet shown on the beat light. */
39
+ let queue: { t: number; beat: number }[] = [];
40
+ let taps: number[] = [];
41
+
42
+ function schedule(): void {
43
+ const ctx = acIfAny();
44
+ if (!ctx || !met.on) {
45
+ return;
46
+ }
47
+ const horizon = ctx.currentTime + LOOKAHEAD_S;
48
+ while (nextClickTime < horizon) {
49
+ const onBeat = tick % met.sub === 0;
50
+ const beat = Math.floor(tick / met.sub) % met.beats;
51
+ if (onBeat || met.subClick) {
52
+ let kind: 'accent' | 'beat' | 'sub' = 'sub';
53
+ if (onBeat) {
54
+ kind = beat === 0 ? 'accent' : 'beat';
55
+ }
56
+ metClick(nextClickTime, kind);
57
+ }
58
+ if (onBeat) {
59
+ queue.push({ t: nextClickTime, beat });
60
+ }
61
+ tick++;
62
+ nextClickTime += 60 / met.bpm / met.sub;
63
+ }
64
+ }
65
+ /** Each animation frame: light up any beat whose click has now sounded. */
66
+ function pulse(): void {
67
+ if (!met.on) {
68
+ return;
69
+ }
70
+ requestAnimationFrame(pulse);
71
+ const ctx = acIfAny();
72
+ if (!ctx) {
73
+ return;
74
+ }
75
+ while (queue.length && queue[0].t <= ctx.currentTime) {
76
+ const due = queue.shift()!;
77
+ met.flash = 'hit' + (due.beat === 0 ? ' one' : '');
78
+ setTimeout(() => {
79
+ met.flash = '';
80
+ }, FLASH_MS);
81
+ met.beatLabel = due.beat + 1 + ' / ' + met.beats;
82
+ }
83
+ }
84
+
85
+ export function metStart(): void {
86
+ const ctx = ac();
87
+ if (!ctx || met.on) {
88
+ return;
89
+ }
90
+ met.on = true;
91
+ tick = 0;
92
+ queue = [];
93
+ nextClickTime = ctx.currentTime + 0.12;
94
+ met.beatLabel = '1 / ' + met.beats;
95
+ schedule();
96
+ timer = setInterval(schedule, SCHEDULER_INTERVAL_MS);
97
+ pulse();
98
+ }
99
+ export function metStop(): void {
100
+ met.on = false;
101
+ if (timer) {
102
+ clearInterval(timer);
103
+ }
104
+ timer = null;
105
+ queue = [];
106
+ met.flash = '';
107
+ met.beatLabel = '—';
108
+ }
109
+ export function metToggle(): void {
110
+ if (met.on) {
111
+ metStop();
112
+ } else {
113
+ metStart();
114
+ }
115
+ }
116
+ export function metSetBpm(bpm: number): void {
117
+ met.bpm = Math.max(BPM_MIN, Math.min(BPM_MAX, Math.round(bpm)));
118
+ const ctx = acIfAny();
119
+ if (met.on && ctx) {
120
+ /* re-anchor so the change takes effect on the next beat, not retroactively */
121
+ nextClickTime = Math.max(ctx.currentTime + 0.06, nextClickTime);
122
+ }
123
+ }
124
+ /** Change the subdivision; a running metronome restarts its count cleanly. */
125
+ export function metSetSub(perBeat: number): void {
126
+ met.sub = perBeat;
127
+ const ctx = acIfAny();
128
+ if (met.on && ctx) {
129
+ tick = 0;
130
+ nextClickTime = Math.max(ctx.currentTime + 0.08, nextClickTime);
131
+ }
132
+ }
133
+ /** Tap tempo: three taps or more set the tempo to their average spacing. */
134
+ export function metTap(): void {
135
+ const now = Date.now();
136
+ taps = taps.filter((time) => now - time < TAP_MEMORY_MS);
137
+ taps.push(now);
138
+ if (taps.length < 3) {
139
+ return;
140
+ }
141
+ const gaps = taps.slice(1).map((time, i) => time - taps[i]);
142
+ const averageGap = gaps.reduce((sum, gap) => sum + gap, 0) / gaps.length;
143
+ if (averageGap > TAP_GAP_MIN_MS && averageGap < TAP_GAP_MAX_MS) {
144
+ metSetBpm(60000 / averageGap);
145
+ }
146
+ }
@@ -0,0 +1,254 @@
1
+ import { ac, acIfAny, audioGate } from './context';
2
+ import { decimate, nsdfPitch } from './pitch';
3
+
4
+ /** What the listener reports on every animation frame while the mic is on. */
5
+ export interface MicReading {
6
+ rms: number;
7
+ /** the app's own sound was playing, so nothing was measured */
8
+ muted?: boolean;
9
+ freq?: number;
10
+ /** median of the last few frames, as a fractional MIDI number */
11
+ midi?: number;
12
+ clarity?: number;
13
+ /** the last three frames agree to within 40 cents */
14
+ stable?: boolean;
15
+ }
16
+
17
+ const mic = {
18
+ on: false,
19
+ stream: null as MediaStream | null,
20
+ an: null as AnalyserNode | null,
21
+ buf: null as Float32Array | null,
22
+ raf: 0,
23
+ floor: 0.004,
24
+ cb: null as ((r: MicReading) => void) | null,
25
+ hist: [] as number[],
26
+ };
27
+
28
+ export function micOn(): boolean {
29
+ return mic.on;
30
+ }
31
+ /** Who hears the readings. One listener at a time: the drill or the Setup check. */
32
+ export function micListen(cb: ((r: MicReading) => void) | null): void {
33
+ mic.cb = cb;
34
+ }
35
+
36
+ /* A microphone can fail in five quite different ways and they need five
37
+ different answers. In particular: inside an embedded frame the browser
38
+ refuses WITHOUT ever showing a prompt, which looks exactly like a dead
39
+ button, and no site setting can override it. */
40
+ export interface MicEnv {
41
+ secure: boolean;
42
+ api: boolean;
43
+ framed: boolean;
44
+ policy: boolean | null;
45
+ }
46
+ export function micEnv(): MicEnv {
47
+ const doc = document as unknown as {
48
+ featurePolicy?: { allowsFeature(feature: string): boolean };
49
+ permissionsPolicy?: { allowsFeature(feature: string): boolean };
50
+ };
51
+ const policy = doc.featurePolicy || doc.permissionsPolicy || null;
52
+ let allowedByPolicy: boolean | null = null;
53
+ let framed: boolean;
54
+ try {
55
+ if (policy && policy.allowsFeature) {
56
+ allowedByPolicy = policy.allowsFeature('microphone');
57
+ }
58
+ } catch {
59
+ allowedByPolicy = null;
60
+ }
61
+ try {
62
+ framed = window.top !== window.self;
63
+ } catch {
64
+ framed = true;
65
+ }
66
+ return {
67
+ secure: !!window.isSecureContext,
68
+ api: !!(navigator.mediaDevices && navigator.mediaDevices.getUserMedia),
69
+ framed,
70
+ policy: allowedByPolicy,
71
+ };
72
+ }
73
+ export function micUsable(): boolean {
74
+ const env = micEnv();
75
+ return env.api && env.secure && env.policy !== false;
76
+ }
77
+ export interface MicReason {
78
+ t: string;
79
+ m: string;
80
+ tab: boolean;
81
+ }
82
+ export function micReason(err: unknown): MicReason {
83
+ const env = micEnv();
84
+ const errorName = (err && (err as { name?: string }).name) || '';
85
+ if (!env.api || !env.secure) {
86
+ return {
87
+ t: 'No microphone API here',
88
+ m: 'This page cannot reach a microphone at all — the connection is not secure, or the browser exposes no capture API in this context.',
89
+ tab: false,
90
+ };
91
+ }
92
+ if (env.policy === false) {
93
+ return {
94
+ t: 'No microphone in this viewer',
95
+ m: 'The page this app runs inside does not pass microphone access through to it, so the prompt never appears and no browser setting can override it. Nothing here can change that — the play-along drills fall back to marking yourself, and everything else is unaffected.',
96
+ tab: false,
97
+ };
98
+ }
99
+ if (errorName === 'NotAllowedError' || errorName === 'SecurityError') {
100
+ return env.framed
101
+ ? {
102
+ t: 'Refused without asking',
103
+ m: 'The browser said no without showing a prompt, which almost always means the embedding page is not passing microphone access through to this frame. Try opening the app in its own browser tab.',
104
+ tab: true,
105
+ }
106
+ : {
107
+ t: 'Permission refused',
108
+ m: 'Tap the padlock or camera icon in the address bar, set Microphone to Allow, then press Start listening again.',
109
+ tab: false,
110
+ };
111
+ }
112
+ if (errorName === 'NotFoundError' || errorName === 'OverconstrainedError') {
113
+ return {
114
+ t: 'No microphone found',
115
+ m: 'The browser reports no capture device on this machine.',
116
+ tab: false,
117
+ };
118
+ }
119
+ if (errorName === 'NotReadableError' || errorName === 'AbortError') {
120
+ return {
121
+ t: 'Microphone is busy',
122
+ m: 'Another app is holding it. Close that app and try again.',
123
+ tab: false,
124
+ };
125
+ }
126
+ return {
127
+ t: 'Could not start the microphone',
128
+ m:
129
+ (errorName || 'unknown error') +
130
+ '. Everything except the play-along drills works without it.',
131
+ tab: env.framed,
132
+ };
133
+ }
134
+
135
+ /** Band kept from the microphone: rumble below a bass's low B goes, and so
136
+ does everything above the pitches the detector looks for. */
137
+ const HIGHPASS_HZ = 32;
138
+ const LOWPASS_HZ = 1400;
139
+ /** Samples per analysis frame. */
140
+ const FRAME_SIZE = 8192;
141
+ /** Pitch detection runs on the signal decimated to about this rate. */
142
+ const ANALYSIS_RATE = 12000;
143
+ /** Readings kept for the median, and how close (in semitones) the last two
144
+ must be to it to count as a stable note. */
145
+ const HISTORY_LENGTH = 5;
146
+ const STABLE_WITHIN = 0.4;
147
+
148
+ export function micStart(): Promise<void> {
149
+ return new Promise((resolve, reject) => {
150
+ if (mic.on) {
151
+ return resolve();
152
+ }
153
+ const ctx = ac();
154
+ if (!ctx) {
155
+ return reject(new Error('no-audio'));
156
+ }
157
+ if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
158
+ return reject(new Error('no-mic'));
159
+ }
160
+ navigator.mediaDevices
161
+ .getUserMedia({
162
+ // raw signal: the browser's voice-call processing would distort pitch
163
+ audio: { echoCancellation: false, noiseSuppression: false, autoGainControl: false },
164
+ })
165
+ .then((stream) => {
166
+ mic.stream = stream;
167
+ const source = ctx.createMediaStreamSource(stream);
168
+ const highpass = ctx.createBiquadFilter();
169
+ highpass.type = 'highpass';
170
+ highpass.frequency.value = HIGHPASS_HZ;
171
+ highpass.Q.value = 0.7;
172
+ const lowpass = ctx.createBiquadFilter();
173
+ lowpass.type = 'lowpass';
174
+ lowpass.frequency.value = LOWPASS_HZ;
175
+ lowpass.Q.value = 0.7;
176
+ const analyser = ctx.createAnalyser();
177
+ analyser.fftSize = FRAME_SIZE;
178
+ analyser.smoothingTimeConstant = 0;
179
+ source.connect(highpass);
180
+ highpass.connect(lowpass);
181
+ lowpass.connect(analyser);
182
+ mic.an = analyser;
183
+ mic.buf = new Float32Array(analyser.fftSize);
184
+ mic.on = true;
185
+ mic.hist = [];
186
+ loop();
187
+ resolve();
188
+ })
189
+ .catch(reject);
190
+ });
191
+ }
192
+ export function micStop(): void {
193
+ if (mic.raf) {
194
+ cancelAnimationFrame(mic.raf);
195
+ }
196
+ mic.raf = 0;
197
+ if (mic.stream) {
198
+ mic.stream.getTracks().forEach((track) => track.stop());
199
+ }
200
+ mic.stream = null;
201
+ mic.on = false;
202
+ mic.cb = null;
203
+ }
204
+
205
+ function rootMeanSquare(samples: Float32Array): number {
206
+ let sum = 0;
207
+ for (let i = 0; i < samples.length; i++) {
208
+ sum += samples[i] * samples[i];
209
+ }
210
+ return Math.sqrt(sum / samples.length);
211
+ }
212
+ /** Frequency to a fractional MIDI number (A4 = 440 Hz = 69). */
213
+ function midiOfFrequency(freq: number): number {
214
+ return 69 + 12 * Math.log2(freq / 440);
215
+ }
216
+ /** Each animation frame: measure, then report to whoever is listening. */
217
+ function loop(): void {
218
+ mic.raf = requestAnimationFrame(loop);
219
+ const ctx = acIfAny();
220
+ if (!mic.an || !mic.cb || !mic.buf || !ctx) {
221
+ return;
222
+ }
223
+ mic.an.getFloatTimeDomainData(mic.buf as Float32Array<ArrayBuffer>);
224
+ const rms = rootMeanSquare(mic.buf);
225
+ if (Date.now() < audioGate.muteUntil) {
226
+ mic.cb({ rms, muted: true });
227
+ return;
228
+ }
229
+ if (rms < mic.floor) {
230
+ mic.hist.length = 0;
231
+ mic.cb({ rms });
232
+ return;
233
+ }
234
+ const factor = Math.max(1, Math.round(ctx.sampleRate / ANALYSIS_RATE));
235
+ const pitch = nsdfPitch(decimate(mic.buf, factor), ctx.sampleRate / factor);
236
+ if (!pitch) {
237
+ mic.cb({ rms });
238
+ return;
239
+ }
240
+ const history = mic.hist;
241
+ history.push(midiOfFrequency(pitch.f));
242
+ if (history.length > HISTORY_LENGTH) {
243
+ history.shift();
244
+ }
245
+ const sorted = history.slice().sort((a, b) => a - b);
246
+ const median = sorted[Math.floor(sorted.length / 2)];
247
+ const last = history[history.length - 1];
248
+ const beforeLast = history[history.length - 2];
249
+ const stable =
250
+ history.length >= 3 &&
251
+ Math.abs(last - median) < STABLE_WITHIN &&
252
+ Math.abs(beforeLast - median) < STABLE_WITHIN;
253
+ mic.cb({ rms, freq: pitch.f, midi: median, clarity: pitch.clarity, stable });
254
+ }