@jxgame2020/dsh-token-quota 0.1.26 → 0.1.28
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/lib/client.js +505 -305
- package/lib/index.js +10 -2
- package/lib/types/client/index.js +6 -1
- package/lib/types/client/locales.js +2 -2
- package/lib/types/client/music.d.ts +64 -49
- package/lib/types/client/music.js +314 -255
- package/lib/types/client/store.js +1 -1
- package/lib/types/index.js +15 -1
- package/lib/types/types.js +1 -1
- package/package.json +1 -1
|
@@ -1,57 +1,71 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
* tool calls, etc.). Three layers:
|
|
2
|
+
* 古风 live-composed soundtrack driven by host actions.
|
|
4
3
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
4
|
+
* Design: when a request starts the engine composes a *piece* — a guzheng-like
|
|
5
|
+
* plucked-string voice (Web Audio synthesis; the MIDI output is switched to
|
|
6
|
+
* the Koto program so hardware synths match). The piece plays phrase after
|
|
7
|
+
* phrase so it stays alive while the model thinks or streams; incoming events
|
|
8
|
+
* steer the piece instead of interrupting it: a step change rotates the mode
|
|
9
|
+
* centre and inserts a fast 过门 (fill); tool calls get a high plink; the
|
|
10
|
+
* ending gets a dizi-like breath note and a descending glissando.
|
|
10
11
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
12
|
+
* **No sustained tones by design.** Every voice is a decaying pluck (≤1.6s)
|
|
13
|
+
* or a fixed-duration breath note — there is no drone/pad oscillator that
|
|
14
|
+
* depends on a later stop event. A lost SSE event can at worst leave the
|
|
15
|
+
* phrase chain running, and the stream watchdog below stops that too, so a
|
|
16
|
+
* hanging "呜呜" hum is physically impossible.
|
|
17
|
+
*
|
|
18
|
+
* Anti-runaway guards:
|
|
19
|
+
* - the host sends an SSE heartbeat every 5s; if it stops arriving the whole
|
|
20
|
+
* engine stops (a dead stream means no stop event will ever come),
|
|
21
|
+
* - every MIDI NoteOn is registered and swept; stops also send
|
|
22
|
+
* All Notes Off (CC 123),
|
|
23
|
+
* - every Web Audio oscillator is explicitly stopped at envelope end.
|
|
15
24
|
*
|
|
16
25
|
* @module @jxgame2020/dsh-token-quota/music
|
|
17
26
|
*/
|
|
18
|
-
/** Scale degrees in semitones from the
|
|
27
|
+
/** Scale degrees in semitones from the (current) tonic. */
|
|
19
28
|
const SCALES = {
|
|
20
29
|
major: [0, 2, 4, 5, 7, 9, 11],
|
|
21
30
|
minor: [0, 2, 3, 5, 7, 8, 10],
|
|
22
|
-
pentatonic: [0, 2, 4, 7, 9],
|
|
31
|
+
pentatonic: [0, 2, 4, 7, 9], // 宫商角徵羽
|
|
23
32
|
};
|
|
24
33
|
/**
|
|
25
|
-
*
|
|
26
|
-
* step advance
|
|
34
|
+
* Tonic offsets for the progression — the piece rotates its mode centre on
|
|
35
|
+
* every step advance. For pentatonic this is 宫 → 羽 → 徵 → 商 (mode
|
|
36
|
+
* rotation on one scale), which is the classic gufeng colour.
|
|
27
37
|
*/
|
|
28
|
-
const
|
|
29
|
-
major: [0, 9, 5, 7],
|
|
30
|
-
minor: [0,
|
|
31
|
-
pentatonic: [0,
|
|
32
|
-
};
|
|
33
|
-
const CHORD_QUALITIES = {
|
|
34
|
-
major: ['major', 'minor', 'major', 'major'],
|
|
35
|
-
minor: ['minor', 'major', 'major', 'major'],
|
|
36
|
-
pentatonic: ['major', 'major', 'major', 'major'],
|
|
38
|
+
const PROGRESSION = {
|
|
39
|
+
major: [0, 9, 5, 7],
|
|
40
|
+
minor: [0, 8, 3, 10],
|
|
41
|
+
pentatonic: [0, 9, 7, 5],
|
|
37
42
|
};
|
|
43
|
+
/**
|
|
44
|
+
* Phrase bank — pentatonic-degree melodies that also read fine on 7-note
|
|
45
|
+
* scales. Degrees index the current scale (values ≥ scale length wrap up an
|
|
46
|
+
* octave).
|
|
47
|
+
*/
|
|
48
|
+
const PHRASES = [
|
|
49
|
+
[{ deg: 0, beats: 2 }, { deg: 1, beats: 1 }, { deg: 2, beats: 1 }, { deg: 3, beats: 2 }, { deg: 2, beats: 1 }, { deg: 1, beats: 1 }, { deg: 0, beats: 3 }],
|
|
50
|
+
[{ deg: 5, beats: 1 }, { deg: 4, beats: 1 }, { deg: 3, beats: 2 }, { deg: 2, beats: 1 }, { deg: 1, beats: 2 }, { deg: 0, beats: 3 }],
|
|
51
|
+
[{ deg: 2, beats: 1 }, { deg: 3, beats: 1 }, { deg: 4, beats: 2 }, { deg: 5, beats: 1 }, { deg: 4, beats: 1 }, { deg: 3, beats: 2 }, { deg: 1, beats: 2 }, { deg: 0, beats: 2 }],
|
|
52
|
+
[{ deg: 4, beats: 2 }, { deg: 3, beats: 1 }, { deg: 2, beats: 1 }, { deg: 3, beats: 2 }, { deg: 1, beats: 1 }, { deg: 2, beats: 1 }, { deg: 0, beats: 4 }],
|
|
53
|
+
];
|
|
54
|
+
/** 过门 — a fast run played when a step (new phase) starts. */
|
|
55
|
+
const FILL = [
|
|
56
|
+
{ deg: 0, beats: 0.5 }, { deg: 1, beats: 0.5 }, { deg: 2, beats: 0.5 }, { deg: 3, beats: 0.5 },
|
|
57
|
+
{ deg: 4, beats: 0.5 }, { deg: 5, beats: 0.5 }, { deg: 4, beats: 0.5 }, { deg: 3, beats: 0.5 },
|
|
58
|
+
];
|
|
38
59
|
/** Turn a MIDI note number into a frequency (equal temperament, A4 = 440). */
|
|
39
60
|
function midiToFreq(midi) {
|
|
40
61
|
return 440 * Math.pow(2, (midi - 69) / 12);
|
|
41
62
|
}
|
|
42
|
-
/**
|
|
43
|
-
|
|
44
|
-
const third = quality === 'major' ? 4 : quality === 'minor' ? 3 : 3;
|
|
45
|
-
const fifth = quality === 'dim' ? 6 : 7;
|
|
46
|
-
return [0, third, fifth];
|
|
47
|
-
}
|
|
63
|
+
/** General-MIDI program closest to a guzheng: 108 Koto (0-indexed 106). */
|
|
64
|
+
const GM_PROGRAM_KOTO = 106;
|
|
48
65
|
/**
|
|
49
|
-
* Live-composed
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
* (the panel switch), and only produces sound while `enabled` is true AND a
|
|
53
|
-
* request is in flight (pad + bass + melody are gated by `startPad` /
|
|
54
|
-
* `stopPad`).
|
|
66
|
+
* Live-composed gufeng engine. Idle until `ensureStarted()` runs inside a
|
|
67
|
+
* user gesture; silent while `enabled` is false; the piece runs only between
|
|
68
|
+
* `request/header` (start) and `assistant/message` / `turn/end` (stop).
|
|
55
69
|
*/
|
|
56
70
|
export class TokenQuotaMusic {
|
|
57
71
|
audio = null;
|
|
@@ -59,36 +73,32 @@ export class TokenQuotaMusic {
|
|
|
59
73
|
midiDetected = false;
|
|
60
74
|
enabled = false;
|
|
61
75
|
volume = 0.5;
|
|
62
|
-
style = '
|
|
76
|
+
style = 'pentatonic';
|
|
63
77
|
// Composition state
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
melodyDegree = 0;
|
|
77
|
-
// Current chord (root midi + quality) for the melody and bass.
|
|
78
|
-
currentRoot = 60; // C4
|
|
79
|
-
currentQuality = 'major';
|
|
80
|
-
/** Base MIDI note of the key. C4 = middle C. */
|
|
78
|
+
playing = false;
|
|
79
|
+
tonicIndex = 0;
|
|
80
|
+
phraseIndex = 0;
|
|
81
|
+
/** True when the next piece start should play the full intro glissando. */
|
|
82
|
+
introPending = true;
|
|
83
|
+
phraseTimer;
|
|
84
|
+
// Watchdog: host heartbeat freshness (ms timestamp)
|
|
85
|
+
lastStreamActivity = 0;
|
|
86
|
+
watchdogTimer;
|
|
87
|
+
// MIDI note registry (anti hanging-note)
|
|
88
|
+
activeMidiNotes = new Set();
|
|
89
|
+
/** Base MIDI note of the tonic. C4 = middle C. */
|
|
81
90
|
static BASE_MIDI = 60;
|
|
82
|
-
/** Beat length in ms —
|
|
83
|
-
static BEAT_MS =
|
|
91
|
+
/** Beat length in ms — the phrase rhythm is measured in these. */
|
|
92
|
+
static BEAT_MS = 480;
|
|
93
|
+
/** Stop everything if the SSE stream goes silent this long. */
|
|
94
|
+
static STREAM_TIMEOUT_MS = 12_000;
|
|
84
95
|
/** True once the audio context has been started (needs a user gesture). */
|
|
85
96
|
get started() {
|
|
86
97
|
return this.audio !== null;
|
|
87
98
|
}
|
|
88
99
|
/**
|
|
89
|
-
* Start the audio context and
|
|
90
|
-
*
|
|
91
|
-
* AudioContext can resume. Safe to call multiple times.
|
|
100
|
+
* Start the audio context and probe for a MIDI output (once). Must be
|
|
101
|
+
* called inside a user gesture so the AudioContext can resume.
|
|
92
102
|
*/
|
|
93
103
|
async ensureStarted() {
|
|
94
104
|
if (this.audio !== null)
|
|
@@ -103,7 +113,9 @@ export class TokenQuotaMusic {
|
|
|
103
113
|
this.audio = null;
|
|
104
114
|
return false;
|
|
105
115
|
}
|
|
106
|
-
// MIDI:
|
|
116
|
+
// MIDI: probe once; switch the receiver to a plucked-string program so
|
|
117
|
+
// the timbre matches the gufeng idea (receivers that ignore program
|
|
118
|
+
// change just keep their own patch).
|
|
107
119
|
if (!this.midiDetected && 'requestMIDIAccess' in navigator) {
|
|
108
120
|
this.midiDetected = true;
|
|
109
121
|
try {
|
|
@@ -112,12 +124,26 @@ export class TokenQuotaMusic {
|
|
|
112
124
|
this.midiOutput = output;
|
|
113
125
|
break;
|
|
114
126
|
}
|
|
127
|
+
try {
|
|
128
|
+
this.midiOutput?.send([0xC0, GM_PROGRAM_KOTO]);
|
|
129
|
+
}
|
|
130
|
+
catch { /* optional */ }
|
|
115
131
|
}
|
|
116
132
|
catch {
|
|
117
|
-
// Permission denied or no MIDI hardware — stick to Web Audio.
|
|
118
133
|
this.midiOutput = null;
|
|
119
134
|
}
|
|
120
135
|
}
|
|
136
|
+
// Stream watchdog runs for the whole engine lifetime; it only acts while
|
|
137
|
+
// a piece is playing.
|
|
138
|
+
if (this.watchdogTimer === undefined) {
|
|
139
|
+
this.lastStreamActivity = Date.now();
|
|
140
|
+
this.watchdogTimer = setInterval(() => {
|
|
141
|
+
if (this.playing && Date.now() - this.lastStreamActivity > TokenQuotaMusic.STREAM_TIMEOUT_MS) {
|
|
142
|
+
// Dead stream: no stop event will ever arrive — stop the piece.
|
|
143
|
+
this.stopPiece(false);
|
|
144
|
+
}
|
|
145
|
+
}, 4_000);
|
|
146
|
+
}
|
|
121
147
|
return true;
|
|
122
148
|
}
|
|
123
149
|
setEnabled(value) {
|
|
@@ -127,261 +153,294 @@ export class TokenQuotaMusic {
|
|
|
127
153
|
}
|
|
128
154
|
setVolume(value) {
|
|
129
155
|
this.volume = Math.max(0, Math.min(1, value));
|
|
130
|
-
if (this.padGain !== null && this.audio !== null) {
|
|
131
|
-
this.padGain.gain.setValueAtTime(this.volume * this.padTarget, this.audio.currentTime);
|
|
132
|
-
}
|
|
133
156
|
}
|
|
134
157
|
setStyle(value) {
|
|
135
158
|
this.style = value;
|
|
136
|
-
// Reset position so the new scale feels immediately.
|
|
137
|
-
this.melodyDegree = 0;
|
|
138
|
-
this.melodyDirection = 1;
|
|
139
159
|
}
|
|
140
|
-
/**
|
|
160
|
+
/** Feed the engine one SSE activity timestamp (action or heartbeat). */
|
|
161
|
+
markStreamActivity() {
|
|
162
|
+
this.lastStreamActivity = Date.now();
|
|
163
|
+
}
|
|
164
|
+
/** Handle one incoming host action and steer the piece. */
|
|
141
165
|
onAction(action) {
|
|
142
166
|
if (!this.enabled || this.audio === null)
|
|
143
167
|
return;
|
|
144
168
|
const base = TokenQuotaMusic.BASE_MIDI;
|
|
145
169
|
switch (action.type) {
|
|
146
170
|
case 'turn/start':
|
|
147
|
-
this.
|
|
148
|
-
this.
|
|
149
|
-
this.
|
|
150
|
-
this.
|
|
151
|
-
this.currentRoot = base;
|
|
152
|
-
this.currentQuality = CHORD_QUALITIES[this.style][0] ?? 'major';
|
|
153
|
-
this.playChord(base, this.currentQuality, 1.6, 0.6);
|
|
171
|
+
this.stopPiece(false);
|
|
172
|
+
this.tonicIndex = 0;
|
|
173
|
+
this.phraseIndex = 0;
|
|
174
|
+
this.introPending = true;
|
|
154
175
|
break;
|
|
155
|
-
case '
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
this.currentRoot = root;
|
|
161
|
-
this.currentQuality = quality;
|
|
162
|
-
if (this.padActive)
|
|
163
|
-
this.crossfadePad(root, quality);
|
|
176
|
+
case 'request/header': {
|
|
177
|
+
if (!this.playing)
|
|
178
|
+
this.startPiece();
|
|
179
|
+
else
|
|
180
|
+
this.pluck(base + 24, 0.3); // light accent on a subsequent request
|
|
164
181
|
break;
|
|
165
182
|
}
|
|
166
|
-
case '
|
|
167
|
-
//
|
|
168
|
-
|
|
169
|
-
this.
|
|
170
|
-
this.
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
this.playNote(note, 0.25, 0.4);
|
|
183
|
+
case 'step/start': {
|
|
184
|
+
// New phase: rotate the mode centre and insert a 过门 fill.
|
|
185
|
+
const prog = PROGRESSION[this.style];
|
|
186
|
+
this.tonicIndex = (this.tonicIndex + 1) % prog.length;
|
|
187
|
+
if (this.playing) {
|
|
188
|
+
const newRoot = base + (prog[this.tonicIndex] ?? 0);
|
|
189
|
+
this.insertFill(newRoot);
|
|
190
|
+
}
|
|
175
191
|
break;
|
|
176
192
|
}
|
|
177
193
|
case 'tool/call':
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
this.
|
|
181
|
-
this.playNote(base - 12, 0.08, 0.3);
|
|
194
|
+
// High plink + low tap — distinct from the plucked melody.
|
|
195
|
+
this.pluck(base + 26, 0.45, 0.5);
|
|
196
|
+
this.pluck(base - 12, 0.3, 0.35);
|
|
182
197
|
break;
|
|
183
198
|
case 'tool/result': {
|
|
184
199
|
if (action.error) {
|
|
185
|
-
|
|
186
|
-
this.
|
|
187
|
-
setTimeout(() => this.playNote(base + 12, 0.25, 0.4), 90);
|
|
200
|
+
this.pluck(base + 13, 0.4, 0.6);
|
|
201
|
+
setTimeout(() => this.pluck(base + 12, 0.4, 0.9), 90);
|
|
188
202
|
}
|
|
189
203
|
else {
|
|
190
|
-
|
|
191
|
-
this.
|
|
192
|
-
setTimeout(() => this.playNote(base + 11, 0.22, 0.35), 70);
|
|
204
|
+
this.pluck(base + 9, 0.35, 0.6);
|
|
205
|
+
setTimeout(() => this.pluck(base + 11, 0.35, 0.9), 70);
|
|
193
206
|
}
|
|
194
207
|
break;
|
|
195
208
|
}
|
|
196
209
|
case 'assistant/attempt':
|
|
197
|
-
// Failed attempt:
|
|
198
|
-
this.
|
|
199
|
-
setTimeout(() => this.
|
|
200
|
-
setTimeout(() => this.
|
|
210
|
+
// Failed attempt: falling tension figure.
|
|
211
|
+
this.pluck(base + 8, 0.4, 0.8);
|
|
212
|
+
setTimeout(() => this.pluck(base + 7, 0.4, 0.8), 110);
|
|
213
|
+
setTimeout(() => this.pluck(base + 5, 0.4, 0.8), 220);
|
|
201
214
|
break;
|
|
202
215
|
case 'assistant/message':
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
else {
|
|
209
|
-
this.playChord(this.currentRoot, this.currentQuality, 1.1, 0.6);
|
|
210
|
-
}
|
|
216
|
+
// Sentence end: stop the phrase chain and breathe. The next
|
|
217
|
+
// request/header (next step or next turn) starts a fresh phrase.
|
|
218
|
+
this.stopMelody();
|
|
219
|
+
this.playing = false;
|
|
220
|
+
this.breath(action.interrupted ? base + 6 : base + 12, 1.6);
|
|
211
221
|
break;
|
|
212
222
|
case 'turn/end':
|
|
213
|
-
this.
|
|
214
|
-
// Final tonic chord, longer release.
|
|
215
|
-
this.playChord(base, this.style === 'minor' ? 'minor' : 'major', 2.4, 0.7);
|
|
216
|
-
this.chordIndex = 0;
|
|
223
|
+
this.stopPiece(true);
|
|
217
224
|
break;
|
|
218
225
|
}
|
|
219
226
|
}
|
|
220
|
-
// ──
|
|
221
|
-
/** Start
|
|
222
|
-
|
|
223
|
-
if (!this.audio)
|
|
227
|
+
// ── piece lifecycle ───────────────────────────────────────────────────
|
|
228
|
+
/** Start a new piece: intro run, then the phrase chain. */
|
|
229
|
+
startPiece() {
|
|
230
|
+
if (!this.audio || this.playing)
|
|
224
231
|
return;
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
const
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
232
|
+
this.playing = true;
|
|
233
|
+
const root = TokenQuotaMusic.BASE_MIDI + (PROGRESSION[this.style][this.tonicIndex] ?? 0);
|
|
234
|
+
const scale = SCALES[this.style];
|
|
235
|
+
if (this.introPending) {
|
|
236
|
+
// Full intro: the ascending guzheng glissando that announces the piece.
|
|
237
|
+
this.introPending = false;
|
|
238
|
+
let t = 0;
|
|
239
|
+
for (let i = 0; i < 8; i++) {
|
|
240
|
+
const note = root + 12 * Math.floor(i / scale.length) + (scale[i % scale.length] ?? 0);
|
|
241
|
+
setTimeout(() => {
|
|
242
|
+
if (this.playing)
|
|
243
|
+
this.pluck(note, 0.3);
|
|
244
|
+
}, t);
|
|
245
|
+
t += 150;
|
|
246
|
+
}
|
|
247
|
+
this.phraseTimer = setTimeout(() => this.playPhrase(), t + 100);
|
|
248
|
+
}
|
|
249
|
+
else {
|
|
250
|
+
// Restart within the same turn: a short three-note pickup, no glissando.
|
|
251
|
+
for (let i = 0; i < 3; i++) {
|
|
252
|
+
setTimeout(() => {
|
|
253
|
+
if (this.playing)
|
|
254
|
+
this.pluck(root + 12 + (scale[i] ?? 0), 0.3);
|
|
255
|
+
}, i * 140);
|
|
256
|
+
}
|
|
257
|
+
this.phraseTimer = setTimeout(() => this.playPhrase(), 480);
|
|
240
258
|
}
|
|
241
|
-
this.padOscs = oscs;
|
|
242
|
-
this.padGain = gain;
|
|
243
|
-
this.padTarget = 0.18;
|
|
244
|
-
gain.gain.linearRampToValueAtTime(this.volume * this.padTarget, now + 0.6);
|
|
245
|
-
this.padActive = true;
|
|
246
259
|
}
|
|
247
|
-
/**
|
|
248
|
-
|
|
249
|
-
if (!this.
|
|
260
|
+
/** Play one phrase, then chain into the next after a one-beat rest. */
|
|
261
|
+
playPhrase() {
|
|
262
|
+
if (!this.playing || !this.audio)
|
|
250
263
|
return;
|
|
251
|
-
const
|
|
252
|
-
const
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
264
|
+
const root = TokenQuotaMusic.BASE_MIDI + (PROGRESSION[this.style][this.tonicIndex] ?? 0);
|
|
265
|
+
const scale = SCALES[this.style];
|
|
266
|
+
// Soft low root pluck under each phrase — a foundation that decays away
|
|
267
|
+
// instead of a sustained drone (nothing here can hang).
|
|
268
|
+
this.pluck(root - 12, 0.28, 1.6);
|
|
269
|
+
const phrase = PHRASES[this.phraseIndex % PHRASES.length];
|
|
270
|
+
this.phraseIndex += 1;
|
|
271
|
+
let t = 0;
|
|
272
|
+
for (const step of phrase) {
|
|
273
|
+
const note = root + (scale[step.deg % scale.length] ?? 0) + 12 * Math.floor(step.deg / scale.length);
|
|
274
|
+
const delay = t;
|
|
275
|
+
setTimeout(() => {
|
|
276
|
+
if (this.playing)
|
|
277
|
+
this.pluck(note, 0.34);
|
|
278
|
+
}, delay);
|
|
279
|
+
t += step.beats * TokenQuotaMusic.BEAT_MS;
|
|
280
|
+
}
|
|
281
|
+
this.phraseTimer = setTimeout(() => this.playPhrase(), t + TokenQuotaMusic.BEAT_MS);
|
|
258
282
|
}
|
|
259
|
-
/**
|
|
260
|
-
|
|
261
|
-
if (!this.audio
|
|
283
|
+
/** Insert a 过门 fill: pause the chain, play the run, resume after. */
|
|
284
|
+
insertFill(root) {
|
|
285
|
+
if (!this.audio)
|
|
262
286
|
return;
|
|
263
|
-
|
|
264
|
-
const
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
osc.disconnect();
|
|
277
|
-
}
|
|
278
|
-
catch { /* ignore */ }
|
|
279
|
-
}
|
|
280
|
-
try {
|
|
281
|
-
gain.disconnect();
|
|
282
|
-
}
|
|
283
|
-
catch { /* ignore */ }
|
|
284
|
-
}, 850);
|
|
285
|
-
this.padActive = false;
|
|
286
|
-
this.padOscs = [];
|
|
287
|
-
this.padGain = null;
|
|
287
|
+
this.stopMelody();
|
|
288
|
+
const scale = SCALES[this.style];
|
|
289
|
+
let t = 0;
|
|
290
|
+
for (const step of FILL) {
|
|
291
|
+
const note = root + (scale[step.deg % scale.length] ?? 0) + 12 * Math.floor(step.deg / scale.length);
|
|
292
|
+
const delay = t;
|
|
293
|
+
setTimeout(() => {
|
|
294
|
+
if (this.playing)
|
|
295
|
+
this.pluck(note, 0.32, 0.7);
|
|
296
|
+
}, delay);
|
|
297
|
+
t += step.beats * TokenQuotaMusic.BEAT_MS;
|
|
298
|
+
}
|
|
299
|
+
this.phraseTimer = setTimeout(() => this.playPhrase(), t + 60);
|
|
288
300
|
}
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
}
|
|
304
|
-
// Melody: eighth-note scale line inside the current chord.
|
|
301
|
+
/** Stop the phrase chain. */
|
|
302
|
+
stopMelody() {
|
|
303
|
+
if (this.phraseTimer !== undefined) {
|
|
304
|
+
clearTimeout(this.phraseTimer);
|
|
305
|
+
this.phraseTimer = undefined;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
/** Stop the whole piece; `final` adds the closing cadence gestures. */
|
|
309
|
+
stopPiece(final) {
|
|
310
|
+
this.stopMelody();
|
|
311
|
+
this.playing = false;
|
|
312
|
+
if (final && this.audio) {
|
|
313
|
+
const base = TokenQuotaMusic.BASE_MIDI;
|
|
314
|
+
// Descending pentatonic glissando + tonic open-fifth pluck chord.
|
|
305
315
|
const scale = SCALES[this.style];
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
if (next >= scale.length) {
|
|
312
|
-
this.melodyDirection = -1;
|
|
313
|
-
next = scale.length - 2;
|
|
314
|
-
}
|
|
315
|
-
else if (next < 0) {
|
|
316
|
-
this.melodyDirection = 1;
|
|
317
|
-
next = 0;
|
|
318
|
-
}
|
|
319
|
-
this.melodyDegree = next;
|
|
320
|
-
degree = next;
|
|
321
|
-
// Make sure we land on a chord tone on the downbeat.
|
|
322
|
-
if (this.beatIndex % 2 === 0) {
|
|
323
|
-
const rootRel = (scale[degree] ?? 0) % 12;
|
|
324
|
-
if (!chordTones.has(rootRel)) {
|
|
325
|
-
// Shift one step toward the nearest chord tone.
|
|
326
|
-
this.melodyDegree += this.melodyDirection;
|
|
327
|
-
degree = this.melodyDegree;
|
|
328
|
-
}
|
|
316
|
+
let t = 0;
|
|
317
|
+
for (let i = 7; i >= 0; i--) {
|
|
318
|
+
const note = base + 12 * Math.floor(i / scale.length) + (scale[i % scale.length] ?? 0);
|
|
319
|
+
setTimeout(() => this.pluck(note, 0.3), t);
|
|
320
|
+
t += 120;
|
|
329
321
|
}
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
tick();
|
|
337
|
-
this.beatTimer = setInterval(tick, TokenQuotaMusic.BEAT_MS);
|
|
338
|
-
}
|
|
339
|
-
stopBeatClock() {
|
|
340
|
-
if (this.beatTimer !== undefined) {
|
|
341
|
-
clearInterval(this.beatTimer);
|
|
342
|
-
this.beatTimer = undefined;
|
|
322
|
+
setTimeout(() => {
|
|
323
|
+
this.pluck(base, 0.5);
|
|
324
|
+
this.pluck(base + 7, 0.45);
|
|
325
|
+
this.pluck(base + 12, 0.4);
|
|
326
|
+
this.pluck(base - 12, 0.5);
|
|
327
|
+
}, t + 150);
|
|
343
328
|
}
|
|
329
|
+
this.allMidiOff();
|
|
344
330
|
}
|
|
345
|
-
|
|
331
|
+
/** Hard stop everything (master switch off / teardown). */
|
|
346
332
|
stopAll() {
|
|
347
|
-
this.
|
|
348
|
-
|
|
333
|
+
this.stopPiece(false);
|
|
334
|
+
}
|
|
335
|
+
// ── voices ────────────────────────────────────────────────────────────
|
|
336
|
+
/**
|
|
337
|
+
* Guzheng-like pluck: a triangle voice plus a soft octave partial through a
|
|
338
|
+
* sweeping lowpass, fast attack and a long exponential decay (notes
|
|
339
|
+
* overlap, which keeps the piece continuous instead of choppy).
|
|
340
|
+
*/
|
|
341
|
+
pluck(midi, velocity, decaySec = 1.3) {
|
|
342
|
+
if (!this.audio)
|
|
343
|
+
return;
|
|
344
|
+
const peak = this.volume * velocity * 0.4;
|
|
345
|
+
if (peak <= 0)
|
|
346
|
+
return;
|
|
347
|
+
const now = this.audio.currentTime;
|
|
348
|
+
const freq = midiToFreq(midi);
|
|
349
|
+
const gain = this.audio.createGain();
|
|
350
|
+
const filter = this.audio.createBiquadFilter();
|
|
351
|
+
filter.type = 'lowpass';
|
|
352
|
+
filter.frequency.setValueAtTime(2400, now);
|
|
353
|
+
filter.frequency.exponentialRampToValueAtTime(420, now + decaySec);
|
|
354
|
+
filter.Q.value = 0.6;
|
|
355
|
+
gain.gain.setValueAtTime(0.0001, now);
|
|
356
|
+
gain.gain.exponentialRampToValueAtTime(peak, now + 0.006);
|
|
357
|
+
gain.gain.exponentialRampToValueAtTime(0.0001, now + decaySec);
|
|
358
|
+
filter.connect(gain);
|
|
359
|
+
gain.connect(this.audio.destination);
|
|
360
|
+
const osc1 = this.audio.createOscillator();
|
|
361
|
+
osc1.type = 'triangle';
|
|
362
|
+
osc1.frequency.value = freq;
|
|
363
|
+
const osc2 = this.audio.createOscillator();
|
|
364
|
+
osc2.type = 'sine';
|
|
365
|
+
osc2.frequency.value = freq * 2; // octave partial (no detune beating)
|
|
366
|
+
const octGain = this.audio.createGain();
|
|
367
|
+
octGain.gain.value = 0.25;
|
|
368
|
+
osc1.connect(filter);
|
|
369
|
+
osc2.connect(octGain);
|
|
370
|
+
octGain.connect(filter);
|
|
371
|
+
osc1.start(now);
|
|
372
|
+
osc1.stop(now + decaySec + 0.1);
|
|
373
|
+
osc2.start(now);
|
|
374
|
+
osc2.stop(now + decaySec + 0.1);
|
|
375
|
+
this.midiNote(midi, decaySec, velocity);
|
|
349
376
|
}
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
playNote(midi, durationSec, velocity) {
|
|
377
|
+
/** Dizi-like breath note: sine with vibrato and a soft envelope. */
|
|
378
|
+
breath(midi, durationSec) {
|
|
353
379
|
if (!this.audio)
|
|
354
380
|
return;
|
|
355
|
-
const peak = this.volume *
|
|
381
|
+
const peak = this.volume * 0.22;
|
|
356
382
|
if (peak <= 0)
|
|
357
383
|
return;
|
|
358
384
|
const now = this.audio.currentTime;
|
|
359
385
|
const osc = this.audio.createOscillator();
|
|
360
386
|
osc.type = 'sine';
|
|
361
387
|
osc.frequency.value = midiToFreq(midi);
|
|
388
|
+
const lfo = this.audio.createOscillator();
|
|
389
|
+
lfo.frequency.value = 5.2;
|
|
390
|
+
const lfoGain = this.audio.createGain();
|
|
391
|
+
lfoGain.gain.value = 4.5; // Hz of vibrato depth
|
|
392
|
+
lfo.connect(lfoGain);
|
|
393
|
+
lfoGain.connect(osc.frequency);
|
|
362
394
|
const gain = this.audio.createGain();
|
|
363
395
|
gain.gain.setValueAtTime(0, now);
|
|
364
|
-
gain.gain.linearRampToValueAtTime(peak, now + 0.
|
|
365
|
-
gain.gain.
|
|
396
|
+
gain.gain.linearRampToValueAtTime(peak, now + 0.18);
|
|
397
|
+
gain.gain.setValueAtTime(peak, now + durationSec * 0.6);
|
|
398
|
+
gain.gain.linearRampToValueAtTime(0.0001, now + durationSec);
|
|
366
399
|
osc.connect(gain);
|
|
367
400
|
gain.connect(this.audio.destination);
|
|
368
401
|
osc.start(now);
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
402
|
+
lfo.start(now);
|
|
403
|
+
osc.stop(now + durationSec + 0.1);
|
|
404
|
+
lfo.stop(now + durationSec + 0.1);
|
|
405
|
+
this.midiNote(midi, durationSec, 0.5);
|
|
406
|
+
}
|
|
407
|
+
// ── MIDI plumbing ─────────────────────────────────────────────────────
|
|
408
|
+
/** Send one MIDI note with a registered NoteOff (anti hanging note). */
|
|
409
|
+
midiNote(midi, durationSec, velocity) {
|
|
410
|
+
if (this.midiOutput === null)
|
|
411
|
+
return;
|
|
412
|
+
const note = midi & 0x7F;
|
|
413
|
+
const vel = Math.max(1, Math.min(127, Math.floor(velocity * 127)));
|
|
414
|
+
try {
|
|
415
|
+
this.midiOutput.send([0x90, note, vel]);
|
|
416
|
+
}
|
|
417
|
+
catch {
|
|
418
|
+
return;
|
|
376
419
|
}
|
|
420
|
+
this.activeMidiNotes.add(note);
|
|
421
|
+
setTimeout(() => {
|
|
422
|
+
try {
|
|
423
|
+
this.midiOutput?.send([0x80, note, 0]);
|
|
424
|
+
}
|
|
425
|
+
catch { /* ignore */ }
|
|
426
|
+
this.activeMidiNotes.delete(note);
|
|
427
|
+
}, Math.max(80, durationSec * 1000));
|
|
377
428
|
}
|
|
378
|
-
/**
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
429
|
+
/** Silence every registered note and send All Notes Off. */
|
|
430
|
+
allMidiOff() {
|
|
431
|
+
if (this.midiOutput === null)
|
|
432
|
+
return;
|
|
433
|
+
for (const note of this.activeMidiNotes) {
|
|
434
|
+
try {
|
|
435
|
+
this.midiOutput.send([0x80, note, 0]);
|
|
436
|
+
}
|
|
437
|
+
catch { /* ignore */ }
|
|
438
|
+
}
|
|
439
|
+
this.activeMidiNotes.clear();
|
|
440
|
+
try {
|
|
441
|
+
this.midiOutput.send([0xB0, 123, 0]);
|
|
383
442
|
}
|
|
384
|
-
|
|
443
|
+
catch { /* ignore */ }
|
|
385
444
|
}
|
|
386
445
|
}
|
|
387
446
|
//# sourceMappingURL=music.js.map
|