@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.
@@ -1,57 +1,71 @@
1
1
  /**
2
- * Live-composed ambient soundtrack driven by host actions (turns, steps,
3
- * tool calls, etc.). Three layers:
2
+ * 古风 live-composed soundtrack driven by host actions.
4
3
  *
5
- * - **Pad**: a sustained three-note chord (triangle waves) that fades in when
6
- * a request starts and crossfades to the next chord on every step.
7
- * - **Bass**: one long root note per beat (lower octave), keeps the pulse.
8
- * - **Melody**: an eighth-note scale line that dances inside the current
9
- * chord — always in key, never a wrong note.
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
- * Outputs through Web Audio (built-in synthesis) by default, and also sends
12
- * MIDI NoteOn/NoteOff to the first available MIDI output when one is detected
13
- * (both play simultaneously when MIDI is present — use the panel volume for
14
- * overall level, the MIDI output has its own gain on the receiver).
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 root. */
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
- * Chord progression roots in semitones from the key centre. Loops — every
26
- * step advance moves one position forward.
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 CHORD_PROGRESSIONS = {
29
- major: [0, 9, 5, 7], // I - vi - IV - V
30
- minor: [0, 9, 4, 11], // i - VI - III - VII
31
- pentatonic: [0, 7, 2, 9], // open fifths feel — root + 5th
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
- /** Interval in semitones from the root for each chord-tone. */
43
- function chordIntervals(quality) {
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 music engine driven by host actions.
50
- *
51
- * The engine is idle until `ensureStarted()` is called from a user gesture
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 = 'major';
76
+ style = 'pentatonic';
63
77
  // Composition state
64
- chordIndex = 0;
65
- stepCount = 0;
66
- toolCount = 0;
67
- // Pad layer (sustained chord)
68
- padOscs = [];
69
- padGain = null;
70
- padTarget = 0;
71
- padActive = false;
72
- // Bass + melody timing (shared beat clock)
73
- beatTimer;
74
- beatIndex = 0; // 0..3 inside a 4-beat bar
75
- melodyDirection = 1;
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 — eighth note = beat / 2. */
83
- static BEAT_MS = 640;
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 try to find a MIDI output.
90
- * Must be called inside a user gesture (the panel switch click) so the
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: try once; we don't re-probe (browser asks for permission only once).
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
- /** Handle one incoming host action and turn it into sound. */
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.stopAll();
148
- this.chordIndex = 0;
149
- this.stepCount = 0;
150
- this.toolCount = 0;
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 'step/start': {
156
- this.chordIndex = (this.chordIndex + 1) % CHORD_PROGRESSIONS[this.style].length;
157
- this.stepCount += 1;
158
- const root = base + (CHORD_PROGRESSIONS[this.style][this.chordIndex] ?? 0);
159
- const quality = CHORD_QUALITIES[this.style][this.chordIndex] ?? 'major';
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 'request/header': {
167
- // Kick off the full three-layer ambient bed — the model is thinking
168
- // and the user is waiting; this is where the soundtrack lives.
169
- this.startPad(this.currentRoot, this.currentQuality);
170
- this.startBeatClock();
171
- // Small melodic flicker on top to mark the exact start.
172
- const degree = (this.stepCount * 2 + this.toolCount) % SCALES[this.style].length;
173
- const note = base + 12 + (SCALES[this.style][degree] ?? 0);
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
- this.toolCount += 1;
179
- // High plink + low percussive tap — stays distinguishable over the pad.
180
- this.playNote(base + 24 + (this.toolCount % 3) * 2, 0.12, 0.45);
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
- // Dissonant downward glint (two half-steps).
186
- this.playNote(base + 13, 0.18, 0.4);
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
- // Upward resolution.
191
- this.playNote(base + 9, 0.14, 0.35);
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: wind down a moment and play a falling tension figure.
198
- this.playNote(base + 8, 0.18, 0.4);
199
- setTimeout(() => this.playNote(base + 7, 0.2, 0.4), 100);
200
- setTimeout(() => this.playNote(base + 5, 0.3, 0.4), 200);
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
- this.stopBeatClock();
204
- this.stopPad();
205
- if (action.interrupted) {
206
- this.playChord(base + 7, 'dim', 0.9, 0.55);
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.stopAll();
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
- // ── pad layer (sustained chord) ────────────────────────────────────────
221
- /** Start the pad from silence, fading in to the target level. */
222
- startPad(rootMidi, quality) {
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
- if (this.padActive)
226
- return;
227
- const now = this.audio.currentTime;
228
- const gain = this.audio.createGain();
229
- gain.gain.setValueAtTime(0, now);
230
- gain.connect(this.audio.destination);
231
- const intervals = chordIntervals(quality);
232
- const oscs = [];
233
- for (const interval of intervals) {
234
- const osc = this.audio.createOscillator();
235
- osc.type = 'triangle';
236
- osc.frequency.value = midiToFreq(rootMidi + interval);
237
- osc.connect(gain);
238
- osc.start(now);
239
- oscs.push(osc);
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
- /** Smoothly morph the pad to a new root + quality. */
248
- crossfadePad(rootMidi, quality) {
249
- if (!this.audio || !this.padActive || this.padGain === null)
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 now = this.audio.currentTime;
252
- const intervals = chordIntervals(quality);
253
- this.padOscs.forEach((osc, i) => {
254
- const interval = intervals[i] ?? 0;
255
- osc.frequency.setValueAtTime(osc.frequency.value, now);
256
- osc.frequency.exponentialRampToValueAtTime(midiToFreq(rootMidi + interval), now + 0.5);
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
- /** Fade the pad to silence, then stop the oscillators. */
260
- stopPad() {
261
- if (!this.audio || !this.padActive || this.padGain === null)
283
+ /** Insert a 过门 fill: pause the chain, play the run, resume after. */
284
+ insertFill(root) {
285
+ if (!this.audio)
262
286
  return;
263
- const now = this.audio.currentTime;
264
- const gain = this.padGain;
265
- gain.gain.cancelScheduledValues(now);
266
- gain.gain.setValueAtTime(gain.gain.value, now);
267
- gain.gain.linearRampToValueAtTime(0, now + 0.8);
268
- const oscs = this.padOscs;
269
- setTimeout(() => {
270
- for (const osc of oscs) {
271
- try {
272
- osc.stop();
273
- }
274
- catch { /* ignore */ }
275
- try {
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
- // ── beat clock (bass + melody) ────────────────────────────────────────
290
- /** Start the per-beat clock that drives bass and melody. */
291
- startBeatClock() {
292
- if (this.beatTimer !== undefined)
293
- return;
294
- this.beatIndex = 0;
295
- this.melodyDegree = 0;
296
- this.melodyDirection = 1;
297
- const tick = () => {
298
- if (!this.audio || !this.enabled)
299
- return;
300
- // Bass note on beats 1 and 3 — root of the current chord, low octave.
301
- if (this.beatIndex % 2 === 0) {
302
- this.playNote(this.currentRoot - 12, 0.45, 0.3);
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
- const chordTones = new Set(chordIntervals(this.currentQuality));
307
- // Walk up and down the scale, preferring chord tones on beats.
308
- let degree = this.melodyDegree;
309
- // Pick the next scale degree in the current direction.
310
- let next = degree + this.melodyDirection;
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
- const note = this.currentRoot + 12 + (scale[degree] ?? 0);
331
- // Melody note — slightly shorter than a beat, staccato feel.
332
- this.playNote(note, 0.22, 0.28);
333
- this.beatIndex = (this.beatIndex + 1) % 4;
334
- };
335
- // First tick right away so the melody starts with the request.
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
- // ── teardown ──────────────────────────────────────────────────────────
331
+ /** Hard stop everything (master switch off / teardown). */
346
332
  stopAll() {
347
- this.stopBeatClock();
348
- this.stopPad();
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
- // ── primitive note / chord playback (shared by all layers) ────────────
351
- /** Play one note: Web Audio + MIDI simultaneously when available. */
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 * velocity * 0.5;
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.008);
365
- gain.gain.exponentialRampToValueAtTime(0.001, now + durationSec);
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
- osc.stop(now + durationSec + 0.05);
370
- if (this.midiOutput !== null) {
371
- const vel = Math.max(1, Math.floor(velocity * 127));
372
- this.midiOutput.send([0x90, midi & 0x7F, vel]);
373
- setTimeout(() => {
374
- this.midiOutput?.send([0x80, midi & 0x7F, 0]);
375
- }, durationSec * 1000);
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
- /** Play a three-note chord plus a bass octave below. */
379
- playChord(rootMidi, quality, durationSec, velocity) {
380
- const intervals = chordIntervals(quality);
381
- for (const interval of intervals) {
382
- this.playNote(rootMidi + interval, durationSec, velocity * 0.45);
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
- this.playNote(rootMidi - 12, durationSec * 1.1, velocity * 0.5);
443
+ catch { /* ignore */ }
385
444
  }
386
445
  }
387
446
  //# sourceMappingURL=music.js.map