@jxgame2020/dsh-token-quota 0.1.26 → 0.1.27

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 CHANGED
@@ -39,7 +39,7 @@ window.__ModuleLoader__.load({
39
39
  dimWhenIdle: false,
40
40
  musicEnabled: false,
41
41
  musicVolume: .5,
42
- musicStyle: "major",
42
+ musicStyle: "pentatonic",
43
43
  musicOnlyCurrentSession: true,
44
44
  upgrade: null,
45
45
  upgradeDismissed: false,
@@ -95,7 +95,7 @@ window.__ModuleLoader__.load({
95
95
  d.musicOnlyCurrentSession = only;
96
96
  },
97
97
  setUpgrade: (d, upgrade) => {
98
- const normalized = upgrade !== null && upgrade.latestVersion !== "" && upgrade.latestVersion !== "0.1.26" ? upgrade : null;
98
+ const normalized = upgrade !== null && upgrade.latestVersion !== "" && upgrade.latestVersion !== "0.1.27" ? upgrade : null;
99
99
  if (d.upgrade === null || d.upgrade.latestVersion !== normalized?.latestVersion) d.upgradeDismissed = false;
100
100
  d.upgrade = normalized;
101
101
  },
@@ -201,92 +201,92 @@ window.__ModuleLoader__.load({
201
201
  document.head.appendChild(tag);
202
202
  }
203
203
  var TokenQuotaPanel_module_css_default = {
204
- "checkBtn": "NPLLIq_checkBtn",
205
- "body": "NPLLIq_body",
206
- "dialogLabel": "NPLLIq_dialogLabel",
207
- "upgradeBanner": "NPLLIq_upgradeBanner",
208
- "dialogActions": "NPLLIq_dialogActions",
209
- "currentBadge": "NPLLIq_currentBadge",
210
- "dialogBody": "NPLLIq_dialogBody",
211
- "notice": "NPLLIq_notice",
212
- "rowMeta": "NPLLIq_rowMeta",
213
- "title": "NPLLIq_title",
214
- "logPagerInfo": "NPLLIq_logPagerInfo",
215
- "headerText": "NPLLIq_headerText",
216
204
  "bar": "NPLLIq_bar",
217
- "noticeError": "NPLLIq_noticeError",
218
205
  "tab": "NPLLIq_tab",
219
- "logScroll": "NPLLIq_logScroll",
220
- "dialogTitle": "NPLLIq_dialogTitle",
221
- "selectBtn": "NPLLIq_selectBtn",
222
- "monitorList": "NPLLIq_monitorList",
223
- "switch": "NPLLIq_switch",
224
- "monitorRow": "NPLLIq_monitorRow",
225
- "resetSelect": "NPLLIq_resetSelect",
226
- "dialogBtn": "NPLLIq_dialogBtn",
227
- "logHeader": "NPLLIq_logHeader",
228
- "panelDim": "NPLLIq_panelDim",
229
- "logToolbar": "NPLLIq_logToolbar",
230
- "upgradeCommands": "NPLLIq_upgradeCommands",
231
- "upgradeHint": "NPLLIq_upgradeHint",
232
- "logDayCol": "NPLLIq_logDayCol",
233
- "settingsBtn": "NPLLIq_settingsBtn",
234
- "tabLabel": "NPLLIq_tabLabel",
235
- "dialogClose": "NPLLIq_dialogClose",
236
- "fillOver": "NPLLIq_fillOver",
237
206
  "rowHeader": "NPLLIq_rowHeader",
238
- "upgradeBannerFlash": "NPLLIq_upgradeBannerFlash",
207
+ "logTable": "NPLLIq_logTable",
208
+ "logPagerInfo": "NPLLIq_logPagerInfo",
239
209
  "copyBtn": "NPLLIq_copyBtn",
240
- "upgradeClose": "NPLLIq_upgradeClose",
210
+ "dialogBody": "NPLLIq_dialogBody",
211
+ "logClearDialog": "NPLLIq_logClearDialog",
212
+ "input": "NPLLIq_input",
213
+ "monitorHint": "NPLLIq_monitorHint",
214
+ "monitorName": "NPLLIq_monitorName",
215
+ "checkErrorHint": "NPLLIq_checkErrorHint",
216
+ "fillWarn": "NPLLIq_fillWarn",
217
+ "dialogBtn": "NPLLIq_dialogBtn",
241
218
  "logModelCol": "NPLLIq_logModelCol",
219
+ "fullNotice": "NPLLIq_fullNotice",
220
+ "logPagerPages": "NPLLIq_logPagerPages",
221
+ "subtitle": "NPLLIq_subtitle",
222
+ "upgradeClose": "NPLLIq_upgradeClose",
223
+ "logClearDate": "NPLLIq_logClearDate",
224
+ "row": "NPLLIq_row",
225
+ "logHeader": "NPLLIq_logHeader",
242
226
  "upgradeCommandRow": "NPLLIq_upgradeCommandRow",
243
- "logPagerBtn": "NPLLIq_logPagerBtn",
227
+ "dialog": "NPLLIq_dialog",
228
+ "upgradeHint": "NPLLIq_upgradeHint",
229
+ "dialogHeader": "NPLLIq_dialogHeader",
230
+ "rowMeta": "NPLLIq_rowMeta",
244
231
  "upgradeBannerText": "NPLLIq_upgradeBannerText",
245
232
  "checkUpdatesLabel": "NPLLIq_checkUpdatesLabel",
246
- "fillWarn": "NPLLIq_fillWarn",
247
- "headerActions": "NPLLIq_headerActions",
248
- "save": "NPLLIq_save",
249
- "checkUpdatesRow": "NPLLIq_checkUpdatesRow",
250
- "settingsBtnActive": "NPLLIq_settingsBtnActive",
251
- "radioRow": "NPLLIq_radioRow",
233
+ "dialogLabel": "NPLLIq_dialogLabel",
234
+ "logPagerBtn": "NPLLIq_logPagerBtn",
252
235
  "logClearBtn": "NPLLIq_logClearBtn",
253
- "logPageSizeSelect": "NPLLIq_logPageSizeSelect",
254
- "dialogHint": "NPLLIq_dialogHint",
255
- "logClearDialog": "NPLLIq_logClearDialog",
256
- "monitorName": "NPLLIq_monitorName",
257
- "dialogBtnPrimary": "NPLLIq_dialogBtnPrimary",
258
- "logPager": "NPLLIq_logPager",
259
- "logClearDate": "NPLLIq_logClearDate",
260
- "rowName": "NPLLIq_rowName",
261
- "logDialog": "NPLLIq_logDialog",
262
- "dialogOverlay": "NPLLIq_dialogOverlay",
236
+ "noticeError": "NPLLIq_noticeError",
263
237
  "logUsedCol": "NPLLIq_logUsedCol",
264
- "input": "NPLLIq_input",
265
- "gearBtn": "NPLLIq_gearBtn",
266
- "row": "NPLLIq_row",
267
- "monitorHint": "NPLLIq_monitorHint",
238
+ "panelDim": "NPLLIq_panelDim",
239
+ "body": "NPLLIq_body",
240
+ "dialogClose": "NPLLIq_dialogClose",
241
+ "selectBtn": "NPLLIq_selectBtn",
242
+ "dialogOverlay": "NPLLIq_dialogOverlay",
268
243
  "header": "NPLLIq_header",
269
- "logPagerPages": "NPLLIq_logPagerPages",
244
+ "dialogActions": "NPLLIq_dialogActions",
245
+ "logDayCol": "NPLLIq_logDayCol",
246
+ "dialogTitle": "NPLLIq_dialogTitle",
247
+ "headerText": "NPLLIq_headerText",
248
+ "collapse": "NPLLIq_collapse",
249
+ "currentBadge": "NPLLIq_currentBadge",
250
+ "panel": "NPLLIq_panel",
251
+ "fillOver": "NPLLIq_fillOver",
252
+ "settingsBtn": "NPLLIq_settingsBtn",
253
+ "gearBtn": "NPLLIq_gearBtn",
254
+ "logPageSizeSelect": "NPLLIq_logPageSizeSelect",
255
+ "dialogHint": "NPLLIq_dialogHint",
270
256
  "versionNew": "NPLLIq_versionNew",
271
- "subtitle": "NPLLIq_subtitle",
272
- "version": "NPLLIq_version",
273
- "fullNotice": "NPLLIq_fullNotice",
274
- "resetRow": "NPLLIq_resetRow",
275
- "logTable": "NPLLIq_logTable",
276
- "fillIdle": "NPLLIq_fillIdle",
277
- "checkErrorHint": "NPLLIq_checkErrorHint",
257
+ "save": "NPLLIq_save",
258
+ "upgradeBannerFlash": "NPLLIq_upgradeBannerFlash",
259
+ "switch": "NPLLIq_switch",
260
+ "logToolbar": "NPLLIq_logToolbar",
278
261
  "tabIcon": "NPLLIq_tabIcon",
279
- "logPagerControls": "NPLLIq_logPagerControls",
280
- "collapse": "NPLLIq_collapse",
281
- "upgradeCommand": "NPLLIq_upgradeCommand",
262
+ "radioRow": "NPLLIq_radioRow",
263
+ "settingsBtnActive": "NPLLIq_settingsBtnActive",
282
264
  "copyBtnDone": "NPLLIq_copyBtnDone",
265
+ "headerActions": "NPLLIq_headerActions",
283
266
  "dialogSection": "NPLLIq_dialogSection",
284
- "controls": "NPLLIq_controls",
285
- "panel": "NPLLIq_panel",
286
- "panelDimStrong": "NPLLIq_panelDimStrong",
267
+ "upgradeBanner": "NPLLIq_upgradeBanner",
268
+ "logScroll": "NPLLIq_logScroll",
269
+ "resetSelect": "NPLLIq_resetSelect",
287
270
  "logSearch": "NPLLIq_logSearch",
288
- "dialogHeader": "NPLLIq_dialogHeader",
289
- "dialog": "NPLLIq_dialog"
271
+ "checkBtn": "NPLLIq_checkBtn",
272
+ "panelDimStrong": "NPLLIq_panelDimStrong",
273
+ "monitorRow": "NPLLIq_monitorRow",
274
+ "dialogBtnPrimary": "NPLLIq_dialogBtnPrimary",
275
+ "upgradeCommands": "NPLLIq_upgradeCommands",
276
+ "checkUpdatesRow": "NPLLIq_checkUpdatesRow",
277
+ "logPager": "NPLLIq_logPager",
278
+ "logPagerControls": "NPLLIq_logPagerControls",
279
+ "logDialog": "NPLLIq_logDialog",
280
+ "rowName": "NPLLIq_rowName",
281
+ "monitorList": "NPLLIq_monitorList",
282
+ "resetRow": "NPLLIq_resetRow",
283
+ "title": "NPLLIq_title",
284
+ "version": "NPLLIq_version",
285
+ "tabLabel": "NPLLIq_tabLabel",
286
+ "notice": "NPLLIq_notice",
287
+ "upgradeCommand": "NPLLIq_upgradeCommand",
288
+ "fillIdle": "NPLLIq_fillIdle",
289
+ "controls": "NPLLIq_controls"
290
290
  };
291
291
  //#endregion
292
292
  //#region lib/types/client/TokenQuotaPanel.js
@@ -592,13 +592,13 @@ window.__ModuleLoader__.load({
592
592
  title: t("upgradeAvailableHint"),
593
593
  children: [
594
594
  "v",
595
- "0.1.26",
595
+ "0.1.27",
596
596
  " → v",
597
597
  upgrade.latestVersion
598
598
  ]
599
599
  }) : (0, react_jsx_runtime.jsxs)("span", {
600
600
  className: TokenQuotaPanel_module_css_default.version,
601
- children: ["v", "0.1.26"]
601
+ children: ["v", "0.1.27"]
602
602
  })]
603
603
  }), (0, react_jsx_runtime.jsx)("div", {
604
604
  className: TokenQuotaPanel_module_css_default.subtitle,
@@ -1232,23 +1232,28 @@ window.__ModuleLoader__.load({
1232
1232
  //#endregion
1233
1233
  //#region lib/types/client/music.js
1234
1234
  /**
1235
- * Live-composed ambient soundtrack driven by host actions (turns, steps,
1236
- * tool calls, etc.). Three layers:
1235
+ * 古风 live-composed soundtrack driven by host actions.
1237
1236
  *
1238
- * - **Pad**: a sustained three-note chord (triangle waves) that fades in when
1239
- * a request starts and crossfades to the next chord on every step.
1240
- * - **Bass**: one long root note per beat (lower octave), keeps the pulse.
1241
- * - **Melody**: an eighth-note scale line that dances inside the current
1242
- * chord — always in key, never a wrong note.
1237
+ * Design: when a request starts the engine composes a *piece* — a guzheng-like
1238
+ * plucked-string voice over a soft open-fifth drone (Web Audio synthesis; the
1239
+ * MIDI output is switched to the Koto program so hardware synths match). The
1240
+ * piece plays phrase after phrase so it never goes quiet while the model
1241
+ * thinks or streams. Incoming events steer the piece instead of interrupting
1242
+ * it: a step change glides the drone to the next mode centre and inserts a
1243
+ * fast 过门 (fill); tool calls get a high plink; the ending gets a flute-like
1244
+ * breath note and a descending glissando.
1243
1245
  *
1244
- * Outputs through Web Audio (built-in synthesis) by default, and also sends
1245
- * MIDI NoteOn/NoteOff to the first available MIDI output when one is detected
1246
- * (both play simultaneously when MIDI is present — use the panel volume for
1247
- * overall level, the MIDI output has its own gain on the receiver).
1246
+ * Anti-runaway guards (the "endless drone" / hanging-MIDI-note problem):
1247
+ * - the host sends an SSE heartbeat every 5s; if it stops arriving the whole
1248
+ * engine fades out (a dead stream means no stop event will ever come),
1249
+ * - every MIDI NoteOn is registered and swept; `stopAll` also sends
1250
+ * All Notes Off (CC 123),
1251
+ * - Web Audio ramps never target zero exponentially and every oscillator is
1252
+ * explicitly stopped.
1248
1253
  *
1249
1254
  * @module @jxgame2020/dsh-token-quota/music
1250
1255
  */
1251
- /** Scale degrees in semitones from the root. */
1256
+ /** Scale degrees in semitones from the (current) tonic. */
1252
1257
  const SCALES = {
1253
1258
  major: [
1254
1259
  0,
@@ -1277,10 +1282,11 @@ window.__ModuleLoader__.load({
1277
1282
  ]
1278
1283
  };
1279
1284
  /**
1280
- * Chord progression roots in semitones from the key centre. Loops — every
1281
- * step advance moves one position forward.
1285
+ * Tonic offsets for the progression — the piece rotates its mode centre on
1286
+ * every step advance. For pentatonic this is 宫 → 羽 → 徵 → 商 (mode
1287
+ * rotation on one scale), which is the classic gufeng colour.
1282
1288
  */
1283
- const CHORD_PROGRESSIONS = {
1289
+ const PROGRESSION = {
1284
1290
  major: [
1285
1291
  0,
1286
1292
  9,
@@ -1289,56 +1295,189 @@ window.__ModuleLoader__.load({
1289
1295
  ],
1290
1296
  minor: [
1291
1297
  0,
1292
- 9,
1293
- 4,
1294
- 11
1298
+ 8,
1299
+ 3,
1300
+ 10
1295
1301
  ],
1296
1302
  pentatonic: [
1297
1303
  0,
1304
+ 9,
1298
1305
  7,
1299
- 2,
1300
- 9
1306
+ 5
1301
1307
  ]
1302
1308
  };
1303
- const CHORD_QUALITIES = {
1304
- major: [
1305
- "major",
1306
- "minor",
1307
- "major",
1308
- "major"
1309
+ /**
1310
+ * Phrase bank — pentatonic-degree melodies that also read fine on 7-note
1311
+ * scales. Degrees index the current scale (values ≥ scale length wrap up an
1312
+ * octave).
1313
+ */
1314
+ const PHRASES = [
1315
+ [
1316
+ {
1317
+ deg: 0,
1318
+ beats: 2
1319
+ },
1320
+ {
1321
+ deg: 1,
1322
+ beats: 1
1323
+ },
1324
+ {
1325
+ deg: 2,
1326
+ beats: 1
1327
+ },
1328
+ {
1329
+ deg: 3,
1330
+ beats: 2
1331
+ },
1332
+ {
1333
+ deg: 2,
1334
+ beats: 1
1335
+ },
1336
+ {
1337
+ deg: 1,
1338
+ beats: 1
1339
+ },
1340
+ {
1341
+ deg: 0,
1342
+ beats: 3
1343
+ }
1309
1344
  ],
1310
- minor: [
1311
- "minor",
1312
- "major",
1313
- "major",
1314
- "major"
1345
+ [
1346
+ {
1347
+ deg: 5,
1348
+ beats: 1
1349
+ },
1350
+ {
1351
+ deg: 4,
1352
+ beats: 1
1353
+ },
1354
+ {
1355
+ deg: 3,
1356
+ beats: 2
1357
+ },
1358
+ {
1359
+ deg: 2,
1360
+ beats: 1
1361
+ },
1362
+ {
1363
+ deg: 1,
1364
+ beats: 2
1365
+ },
1366
+ {
1367
+ deg: 0,
1368
+ beats: 3
1369
+ }
1315
1370
  ],
1316
- pentatonic: [
1317
- "major",
1318
- "major",
1319
- "major",
1320
- "major"
1371
+ [
1372
+ {
1373
+ deg: 2,
1374
+ beats: 1
1375
+ },
1376
+ {
1377
+ deg: 3,
1378
+ beats: 1
1379
+ },
1380
+ {
1381
+ deg: 4,
1382
+ beats: 2
1383
+ },
1384
+ {
1385
+ deg: 5,
1386
+ beats: 1
1387
+ },
1388
+ {
1389
+ deg: 4,
1390
+ beats: 1
1391
+ },
1392
+ {
1393
+ deg: 3,
1394
+ beats: 2
1395
+ },
1396
+ {
1397
+ deg: 1,
1398
+ beats: 2
1399
+ },
1400
+ {
1401
+ deg: 0,
1402
+ beats: 2
1403
+ }
1404
+ ],
1405
+ [
1406
+ {
1407
+ deg: 4,
1408
+ beats: 2
1409
+ },
1410
+ {
1411
+ deg: 3,
1412
+ beats: 1
1413
+ },
1414
+ {
1415
+ deg: 2,
1416
+ beats: 1
1417
+ },
1418
+ {
1419
+ deg: 3,
1420
+ beats: 2
1421
+ },
1422
+ {
1423
+ deg: 1,
1424
+ beats: 1
1425
+ },
1426
+ {
1427
+ deg: 2,
1428
+ beats: 1
1429
+ },
1430
+ {
1431
+ deg: 0,
1432
+ beats: 4
1433
+ }
1321
1434
  ]
1322
- };
1435
+ ];
1436
+ /** 过门 — a fast run played when a step (new phase) starts. */
1437
+ const FILL = [
1438
+ {
1439
+ deg: 0,
1440
+ beats: .5
1441
+ },
1442
+ {
1443
+ deg: 1,
1444
+ beats: .5
1445
+ },
1446
+ {
1447
+ deg: 2,
1448
+ beats: .5
1449
+ },
1450
+ {
1451
+ deg: 3,
1452
+ beats: .5
1453
+ },
1454
+ {
1455
+ deg: 4,
1456
+ beats: .5
1457
+ },
1458
+ {
1459
+ deg: 5,
1460
+ beats: .5
1461
+ },
1462
+ {
1463
+ deg: 4,
1464
+ beats: .5
1465
+ },
1466
+ {
1467
+ deg: 3,
1468
+ beats: .5
1469
+ }
1470
+ ];
1323
1471
  /** Turn a MIDI note number into a frequency (equal temperament, A4 = 440). */
1324
1472
  function midiToFreq(midi) {
1325
1473
  return 440 * Math.pow(2, (midi - 69) / 12);
1326
1474
  }
1327
- /** Interval in semitones from the root for each chord-tone. */
1328
- function chordIntervals(quality) {
1329
- return [
1330
- 0,
1331
- quality === "major" ? 4 : quality === "minor" ? 3 : 3,
1332
- quality === "dim" ? 6 : 7
1333
- ];
1334
- }
1475
+ /** General-MIDI program closest to a guzheng: 108 Koto (0-indexed 106). */
1476
+ const GM_PROGRAM_KOTO = 106;
1335
1477
  /**
1336
- * Live-composed music engine driven by host actions.
1337
- *
1338
- * The engine is idle until `ensureStarted()` is called from a user gesture
1339
- * (the panel switch), and only produces sound while `enabled` is true AND a
1340
- * request is in flight (pad + bass + melody are gated by `startPad` /
1341
- * `stopPad`).
1478
+ * Live-composed gufeng engine. Idle until `ensureStarted()` runs inside a
1479
+ * user gesture; silent while `enabled` is false; the piece runs only between
1480
+ * `request/header` (start) and `assistant/message` / `turn/end` (stop).
1342
1481
  */
1343
1482
  var TokenQuotaMusic = class TokenQuotaMusic {
1344
1483
  audio = null;
@@ -1346,32 +1485,31 @@ window.__ModuleLoader__.load({
1346
1485
  midiDetected = false;
1347
1486
  enabled = false;
1348
1487
  volume = .5;
1349
- style = "major";
1350
- chordIndex = 0;
1351
- stepCount = 0;
1352
- toolCount = 0;
1353
- padOscs = [];
1354
- padGain = null;
1355
- padTarget = 0;
1356
- padActive = false;
1357
- beatTimer;
1358
- beatIndex = 0;
1359
- melodyDirection = 1;
1360
- melodyDegree = 0;
1361
- currentRoot = 60;
1362
- currentQuality = "major";
1363
- /** Base MIDI note of the key. C4 = middle C. */
1488
+ style = "pentatonic";
1489
+ playing = false;
1490
+ tonicIndex = 0;
1491
+ phraseIndex = 0;
1492
+ phraseTimer;
1493
+ droneOscs = [];
1494
+ droneGain = null;
1495
+ droneFilter = null;
1496
+ droneActive = false;
1497
+ lastStreamActivity = 0;
1498
+ watchdogTimer;
1499
+ activeMidiNotes = /* @__PURE__ */ new Set();
1500
+ /** Base MIDI note of the tonic. C4 = middle C. */
1364
1501
  static BASE_MIDI = 60;
1365
- /** Beat length in ms — eighth note = beat / 2. */
1366
- static BEAT_MS = 640;
1502
+ /** Beat length in ms — the phrase rhythm is measured in these. */
1503
+ static BEAT_MS = 480;
1504
+ /** Stop everything if the SSE stream goes silent this long. */
1505
+ static STREAM_TIMEOUT_MS = 12e3;
1367
1506
  /** True once the audio context has been started (needs a user gesture). */
1368
1507
  get started() {
1369
1508
  return this.audio !== null;
1370
1509
  }
1371
1510
  /**
1372
- * Start the audio context and try to find a MIDI output.
1373
- * Must be called inside a user gesture (the panel switch click) so the
1374
- * AudioContext can resume. Safe to call multiple times.
1511
+ * Start the audio context and probe for a MIDI output (once). Must be
1512
+ * called inside a user gesture so the AudioContext can resume.
1375
1513
  */
1376
1514
  async ensureStarted() {
1377
1515
  if (this.audio !== null) return true;
@@ -1391,10 +1529,19 @@ window.__ModuleLoader__.load({
1391
1529
  this.midiOutput = output;
1392
1530
  break;
1393
1531
  }
1532
+ try {
1533
+ this.midiOutput?.send([192, GM_PROGRAM_KOTO]);
1534
+ } catch {}
1394
1535
  } catch {
1395
1536
  this.midiOutput = null;
1396
1537
  }
1397
1538
  }
1539
+ if (this.watchdogTimer === void 0) {
1540
+ this.lastStreamActivity = Date.now();
1541
+ this.watchdogTimer = setInterval(() => {
1542
+ if (this.playing && Date.now() - this.lastStreamActivity > TokenQuotaMusic.STREAM_TIMEOUT_MS) this.stopPiece(false);
1543
+ }, 4e3);
1544
+ }
1398
1545
  return true;
1399
1546
  }
1400
1547
  setEnabled(value) {
@@ -1403,121 +1550,196 @@ window.__ModuleLoader__.load({
1403
1550
  }
1404
1551
  setVolume(value) {
1405
1552
  this.volume = Math.max(0, Math.min(1, value));
1406
- if (this.padGain !== null && this.audio !== null) this.padGain.gain.setValueAtTime(this.volume * this.padTarget, this.audio.currentTime);
1407
1553
  }
1408
1554
  setStyle(value) {
1409
1555
  this.style = value;
1410
- this.melodyDegree = 0;
1411
- this.melodyDirection = 1;
1412
1556
  }
1413
- /** Handle one incoming host action and turn it into sound. */
1557
+ /** Feed the engine one SSE activity timestamp (action or heartbeat). */
1558
+ markStreamActivity() {
1559
+ this.lastStreamActivity = Date.now();
1560
+ }
1561
+ /** Handle one incoming host action and steer the piece. */
1414
1562
  onAction(action) {
1415
1563
  if (!this.enabled || this.audio === null) return;
1416
1564
  const base = TokenQuotaMusic.BASE_MIDI;
1417
1565
  switch (action.type) {
1418
1566
  case "turn/start":
1419
- this.stopAll();
1420
- this.chordIndex = 0;
1421
- this.stepCount = 0;
1422
- this.toolCount = 0;
1423
- this.currentRoot = base;
1424
- this.currentQuality = CHORD_QUALITIES[this.style][0] ?? "major";
1425
- this.playChord(base, this.currentQuality, 1.6, .6);
1567
+ this.stopPiece(false);
1568
+ this.tonicIndex = 0;
1569
+ this.phraseIndex = 0;
1426
1570
  break;
1427
- case "step/start": {
1428
- this.chordIndex = (this.chordIndex + 1) % CHORD_PROGRESSIONS[this.style].length;
1429
- this.stepCount += 1;
1430
- const root = base + (CHORD_PROGRESSIONS[this.style][this.chordIndex] ?? 0);
1431
- const quality = CHORD_QUALITIES[this.style][this.chordIndex] ?? "major";
1432
- this.currentRoot = root;
1433
- this.currentQuality = quality;
1434
- if (this.padActive) this.crossfadePad(root, quality);
1571
+ case "request/header":
1572
+ if (!this.playing) this.startPiece();
1573
+ else this.pluck(base + 24, .3);
1435
1574
  break;
1436
- }
1437
- case "request/header": {
1438
- this.startPad(this.currentRoot, this.currentQuality);
1439
- this.startBeatClock();
1440
- const degree = (this.stepCount * 2 + this.toolCount) % SCALES[this.style].length;
1441
- const note = base + 12 + (SCALES[this.style][degree] ?? 0);
1442
- this.playNote(note, .25, .4);
1575
+ case "step/start": {
1576
+ const prog = PROGRESSION[this.style];
1577
+ this.tonicIndex = (this.tonicIndex + 1) % prog.length;
1578
+ const newRoot = base + (prog[this.tonicIndex] ?? 0);
1579
+ this.glideDrone(newRoot);
1580
+ if (this.playing) this.insertFill(newRoot);
1443
1581
  break;
1444
1582
  }
1445
1583
  case "tool/call":
1446
- this.toolCount += 1;
1447
- this.playNote(base + 24 + this.toolCount % 3 * 2, .12, .45);
1448
- this.playNote(base - 12, .08, .3);
1584
+ this.pluck(base + 26, .45, .5);
1585
+ this.pluck(base - 12, .3, .35);
1449
1586
  break;
1450
1587
  case "tool/result":
1451
1588
  if (action.error) {
1452
- this.playNote(base + 13, .18, .4);
1453
- setTimeout(() => this.playNote(base + 12, .25, .4), 90);
1589
+ this.pluck(base + 13, .4, .6);
1590
+ setTimeout(() => this.pluck(base + 12, .4, .9), 90);
1454
1591
  } else {
1455
- this.playNote(base + 9, .14, .35);
1456
- setTimeout(() => this.playNote(base + 11, .22, .35), 70);
1592
+ this.pluck(base + 9, .35, .6);
1593
+ setTimeout(() => this.pluck(base + 11, .35, .9), 70);
1457
1594
  }
1458
1595
  break;
1459
1596
  case "assistant/attempt":
1460
- this.playNote(base + 8, .18, .4);
1461
- setTimeout(() => this.playNote(base + 7, .2, .4), 100);
1462
- setTimeout(() => this.playNote(base + 5, .3, .4), 200);
1597
+ this.pluck(base + 8, .4, .8);
1598
+ setTimeout(() => this.pluck(base + 7, .4, .8), 110);
1599
+ setTimeout(() => this.pluck(base + 5, .4, .8), 220);
1463
1600
  break;
1464
1601
  case "assistant/message":
1465
- this.stopBeatClock();
1466
- this.stopPad();
1467
- if (action.interrupted) this.playChord(base + 7, "dim", .9, .55);
1468
- else this.playChord(this.currentRoot, this.currentQuality, 1.1, .6);
1602
+ this.stopMelody();
1603
+ this.breath(action.interrupted ? base + 6 : base + 12, 1.6);
1469
1604
  break;
1470
1605
  case "turn/end":
1471
- this.stopAll();
1472
- this.playChord(base, this.style === "minor" ? "minor" : "major", 2.4, .7);
1473
- this.chordIndex = 0;
1606
+ this.stopPiece(true);
1474
1607
  break;
1475
1608
  }
1476
1609
  }
1477
- /** Start the pad from silence, fading in to the target level. */
1478
- startPad(rootMidi, quality) {
1610
+ /** Start a new piece: drone + ascending glissando + phrase chain. */
1611
+ startPiece() {
1612
+ if (!this.audio || this.playing) return;
1613
+ this.playing = true;
1614
+ this.tonicIndex = 0;
1615
+ this.phraseIndex = 0;
1616
+ const root = TokenQuotaMusic.BASE_MIDI + (PROGRESSION[this.style][0] ?? 0);
1617
+ this.startDrone(root);
1618
+ const scale = SCALES[this.style];
1619
+ let t = 0;
1620
+ for (let i = 0; i < 8; i++) {
1621
+ const deg = i % scale.length;
1622
+ const note = root + 12 * Math.floor(i / scale.length) + (scale[deg] ?? 0);
1623
+ setTimeout(() => this.pluck(note, .3), t);
1624
+ t += 150;
1625
+ }
1626
+ this.phraseTimer = setTimeout(() => this.playPhrase(), t + 100);
1627
+ }
1628
+ /** Play one phrase, then chain into the next after a one-beat rest. */
1629
+ playPhrase() {
1630
+ if (!this.playing || !this.audio) return;
1631
+ const root = TokenQuotaMusic.BASE_MIDI + (PROGRESSION[this.style][this.tonicIndex] ?? 0);
1632
+ const scale = SCALES[this.style];
1633
+ const phrase = PHRASES[this.phraseIndex % PHRASES.length];
1634
+ this.phraseIndex += 1;
1635
+ let t = 0;
1636
+ for (const step of phrase) {
1637
+ const note = root + (scale[step.deg % scale.length] ?? 0) + 12 * Math.floor(step.deg / scale.length);
1638
+ setTimeout(() => {
1639
+ if (this.playing) this.pluck(note, .34);
1640
+ }, t);
1641
+ t += step.beats * TokenQuotaMusic.BEAT_MS;
1642
+ }
1643
+ this.phraseTimer = setTimeout(() => this.playPhrase(), t + TokenQuotaMusic.BEAT_MS);
1644
+ }
1645
+ /** Insert a 过门 fill: pause the chain, play the run, resume after. */
1646
+ insertFill(root) {
1479
1647
  if (!this.audio) return;
1480
- if (this.padActive) return;
1648
+ this.stopMelody();
1649
+ const scale = SCALES[this.style];
1650
+ let t = 0;
1651
+ for (const step of FILL) {
1652
+ const note = root + (scale[step.deg % scale.length] ?? 0) + 12 * Math.floor(step.deg / scale.length);
1653
+ setTimeout(() => {
1654
+ if (this.playing) this.pluck(note, .32, .7);
1655
+ }, t);
1656
+ t += step.beats * TokenQuotaMusic.BEAT_MS;
1657
+ }
1658
+ this.phraseTimer = setTimeout(() => this.playPhrase(), t + 60);
1659
+ }
1660
+ /** Stop the melody chain (drone keeps fading naturally). */
1661
+ stopMelody() {
1662
+ if (this.phraseTimer !== void 0) {
1663
+ clearTimeout(this.phraseTimer);
1664
+ this.phraseTimer = void 0;
1665
+ }
1666
+ }
1667
+ /** Stop the whole piece; `final` adds the closing cadence gestures. */
1668
+ stopPiece(final) {
1669
+ this.stopMelody();
1670
+ this.playing = false;
1671
+ if (final && this.audio) {
1672
+ const base = TokenQuotaMusic.BASE_MIDI;
1673
+ const scale = SCALES[this.style];
1674
+ let t = 0;
1675
+ for (let i = 7; i >= 0; i--) {
1676
+ const deg = i % scale.length;
1677
+ const note = base + 12 * Math.floor(i / scale.length) + (scale[deg] ?? 0);
1678
+ setTimeout(() => this.pluck(note, .3), t);
1679
+ t += 120;
1680
+ }
1681
+ setTimeout(() => {
1682
+ this.pluck(base, .5);
1683
+ this.pluck(base + 7, .45);
1684
+ this.pluck(base + 12, .4);
1685
+ this.pluck(base - 12, .5);
1686
+ }, t + 150);
1687
+ }
1688
+ this.fadeDrone(final ? 1.2 : .8);
1689
+ this.allMidiOff();
1690
+ }
1691
+ /** Hard stop everything (master switch off / teardown). */
1692
+ stopAll() {
1693
+ this.stopPiece(false);
1694
+ }
1695
+ /** Start the sustained open fifth (root + fifth below octave root). */
1696
+ startDrone(root) {
1697
+ if (!this.audio || this.droneActive) return;
1481
1698
  const now = this.audio.currentTime;
1482
1699
  const gain = this.audio.createGain();
1483
1700
  gain.gain.setValueAtTime(0, now);
1484
- gain.connect(this.audio.destination);
1485
- const intervals = chordIntervals(quality);
1701
+ const filter = this.audio.createBiquadFilter();
1702
+ filter.type = "lowpass";
1703
+ filter.frequency.value = 620;
1704
+ filter.Q.value = .4;
1705
+ gain.connect(filter);
1706
+ filter.connect(this.audio.destination);
1486
1707
  const oscs = [];
1487
- for (const interval of intervals) {
1708
+ for (const interval of [-12, -5]) {
1488
1709
  const osc = this.audio.createOscillator();
1489
- osc.type = "triangle";
1490
- osc.frequency.value = midiToFreq(rootMidi + interval);
1710
+ osc.type = "sine";
1711
+ osc.frequency.value = midiToFreq(root + interval);
1491
1712
  osc.connect(gain);
1492
1713
  osc.start(now);
1493
1714
  oscs.push(osc);
1494
1715
  }
1495
- this.padOscs = oscs;
1496
- this.padGain = gain;
1497
- this.padTarget = .18;
1498
- gain.gain.linearRampToValueAtTime(this.volume * this.padTarget, now + .6);
1499
- this.padActive = true;
1716
+ this.droneOscs = oscs;
1717
+ this.droneGain = gain;
1718
+ this.droneFilter = filter;
1719
+ this.droneActive = true;
1720
+ gain.gain.linearRampToValueAtTime(this.volume * .07, now + 1.4);
1500
1721
  }
1501
- /** Smoothly morph the pad to a new root + quality. */
1502
- crossfadePad(rootMidi, quality) {
1503
- if (!this.audio || !this.padActive || this.padGain === null) return;
1722
+ /** Glide the drone to a new tonic (mode rotation). */
1723
+ glideDrone(root) {
1724
+ if (!this.audio || !this.droneActive) return;
1504
1725
  const now = this.audio.currentTime;
1505
- const intervals = chordIntervals(quality);
1506
- this.padOscs.forEach((osc, i) => {
1507
- const interval = intervals[i] ?? 0;
1726
+ const targets = [root - 12, root - 5];
1727
+ this.droneOscs.forEach((osc, i) => {
1728
+ const target = targets[i] ?? root;
1508
1729
  osc.frequency.setValueAtTime(osc.frequency.value, now);
1509
- osc.frequency.exponentialRampToValueAtTime(midiToFreq(rootMidi + interval), now + .5);
1730
+ osc.frequency.exponentialRampToValueAtTime(midiToFreq(target), now + .9);
1510
1731
  });
1511
1732
  }
1512
- /** Fade the pad to silence, then stop the oscillators. */
1513
- stopPad() {
1514
- if (!this.audio || !this.padActive || this.padGain === null) return;
1733
+ /** Fade the drone out and stop its oscillators. */
1734
+ fadeDrone(seconds) {
1735
+ if (!this.audio || !this.droneActive || this.droneGain === null) return;
1515
1736
  const now = this.audio.currentTime;
1516
- const gain = this.padGain;
1737
+ const gain = this.droneGain;
1517
1738
  gain.gain.cancelScheduledValues(now);
1518
- gain.gain.setValueAtTime(gain.gain.value, now);
1519
- gain.gain.linearRampToValueAtTime(0, now + .8);
1520
- const oscs = this.padOscs;
1739
+ gain.gain.setValueAtTime(Math.max(gain.gain.value, 1e-4), now);
1740
+ gain.gain.exponentialRampToValueAtTime(1e-4, now + seconds);
1741
+ const oscs = this.droneOscs;
1742
+ const filter = this.droneFilter;
1521
1743
  setTimeout(() => {
1522
1744
  for (const osc of oscs) {
1523
1745
  try {
@@ -1527,98 +1749,129 @@ window.__ModuleLoader__.load({
1527
1749
  osc.disconnect();
1528
1750
  } catch {}
1529
1751
  }
1752
+ try {
1753
+ filter?.disconnect();
1754
+ } catch {}
1530
1755
  try {
1531
1756
  gain.disconnect();
1532
1757
  } catch {}
1533
- }, 850);
1534
- this.padActive = false;
1535
- this.padOscs = [];
1536
- this.padGain = null;
1537
- }
1538
- /** Start the per-beat clock that drives bass and melody. */
1539
- startBeatClock() {
1540
- if (this.beatTimer !== void 0) return;
1541
- this.beatIndex = 0;
1542
- this.melodyDegree = 0;
1543
- this.melodyDirection = 1;
1544
- const tick = () => {
1545
- if (!this.audio || !this.enabled) return;
1546
- if (this.beatIndex % 2 === 0) this.playNote(this.currentRoot - 12, .45, .3);
1547
- const scale = SCALES[this.style];
1548
- const chordTones = new Set(chordIntervals(this.currentQuality));
1549
- let degree = this.melodyDegree;
1550
- let next = degree + this.melodyDirection;
1551
- if (next >= scale.length) {
1552
- this.melodyDirection = -1;
1553
- next = scale.length - 2;
1554
- } else if (next < 0) {
1555
- this.melodyDirection = 1;
1556
- next = 0;
1557
- }
1558
- this.melodyDegree = next;
1559
- degree = next;
1560
- if (this.beatIndex % 2 === 0) {
1561
- const rootRel = (scale[degree] ?? 0) % 12;
1562
- if (!chordTones.has(rootRel)) {
1563
- this.melodyDegree += this.melodyDirection;
1564
- degree = this.melodyDegree;
1565
- }
1566
- }
1567
- const note = this.currentRoot + 12 + (scale[degree] ?? 0);
1568
- this.playNote(note, .22, .28);
1569
- this.beatIndex = (this.beatIndex + 1) % 4;
1570
- };
1571
- tick();
1572
- this.beatTimer = setInterval(tick, TokenQuotaMusic.BEAT_MS);
1758
+ }, seconds * 1e3 + 120);
1759
+ this.droneActive = false;
1760
+ this.droneOscs = [];
1761
+ this.droneGain = null;
1762
+ this.droneFilter = null;
1573
1763
  }
1574
- stopBeatClock() {
1575
- if (this.beatTimer !== void 0) {
1576
- clearInterval(this.beatTimer);
1577
- this.beatTimer = void 0;
1578
- }
1579
- }
1580
- stopAll() {
1581
- this.stopBeatClock();
1582
- this.stopPad();
1764
+ /**
1765
+ * Guzheng-like pluck: two slightly detuned oscillators through a sweeping
1766
+ * lowpass, fast attack and a long exponential decay (notes overlap, which
1767
+ * keeps the piece continuous instead of choppy).
1768
+ */
1769
+ pluck(midi, velocity, decaySec = 1.3) {
1770
+ if (!this.audio) return;
1771
+ const peak = this.volume * velocity * .4;
1772
+ if (peak <= 0) return;
1773
+ const now = this.audio.currentTime;
1774
+ const freq = midiToFreq(midi);
1775
+ const gain = this.audio.createGain();
1776
+ const filter = this.audio.createBiquadFilter();
1777
+ filter.type = "lowpass";
1778
+ filter.frequency.setValueAtTime(2400, now);
1779
+ filter.frequency.exponentialRampToValueAtTime(420, now + decaySec);
1780
+ filter.Q.value = .6;
1781
+ gain.gain.setValueAtTime(1e-4, now);
1782
+ gain.gain.exponentialRampToValueAtTime(peak, now + .006);
1783
+ gain.gain.exponentialRampToValueAtTime(1e-4, now + decaySec);
1784
+ filter.connect(gain);
1785
+ gain.connect(this.audio.destination);
1786
+ const osc1 = this.audio.createOscillator();
1787
+ osc1.type = "triangle";
1788
+ osc1.frequency.value = freq;
1789
+ const osc2 = this.audio.createOscillator();
1790
+ osc2.type = "sine";
1791
+ osc2.frequency.value = freq * 2.001;
1792
+ const octGain = this.audio.createGain();
1793
+ octGain.gain.value = .35;
1794
+ osc1.connect(filter);
1795
+ osc2.connect(octGain);
1796
+ octGain.connect(filter);
1797
+ osc1.start(now);
1798
+ osc1.stop(now + decaySec + .1);
1799
+ osc2.start(now);
1800
+ osc2.stop(now + decaySec + .1);
1801
+ this.midiNote(midi, decaySec, velocity);
1583
1802
  }
1584
- /** Play one note: Web Audio + MIDI simultaneously when available. */
1585
- playNote(midi, durationSec, velocity) {
1803
+ /** Dizi-like breath note: sine with vibrato and a soft envelope. */
1804
+ breath(midi, durationSec) {
1586
1805
  if (!this.audio) return;
1587
- const peak = this.volume * velocity * .5;
1806
+ const peak = this.volume * .22;
1588
1807
  if (peak <= 0) return;
1589
1808
  const now = this.audio.currentTime;
1590
1809
  const osc = this.audio.createOscillator();
1591
1810
  osc.type = "sine";
1592
1811
  osc.frequency.value = midiToFreq(midi);
1812
+ const lfo = this.audio.createOscillator();
1813
+ lfo.frequency.value = 5.2;
1814
+ const lfoGain = this.audio.createGain();
1815
+ lfoGain.gain.value = 4.5;
1816
+ lfo.connect(lfoGain);
1817
+ lfoGain.connect(osc.frequency);
1593
1818
  const gain = this.audio.createGain();
1594
1819
  gain.gain.setValueAtTime(0, now);
1595
- gain.gain.linearRampToValueAtTime(peak, now + .008);
1596
- gain.gain.exponentialRampToValueAtTime(.001, now + durationSec);
1820
+ gain.gain.linearRampToValueAtTime(peak, now + .18);
1821
+ gain.gain.setValueAtTime(peak, now + durationSec * .6);
1822
+ gain.gain.linearRampToValueAtTime(1e-4, now + durationSec);
1597
1823
  osc.connect(gain);
1598
1824
  gain.connect(this.audio.destination);
1599
1825
  osc.start(now);
1600
- osc.stop(now + durationSec + .05);
1601
- if (this.midiOutput !== null) {
1602
- const vel = Math.max(1, Math.floor(velocity * 127));
1826
+ lfo.start(now);
1827
+ osc.stop(now + durationSec + .1);
1828
+ lfo.stop(now + durationSec + .1);
1829
+ this.midiNote(midi, durationSec, .5);
1830
+ }
1831
+ /** Send one MIDI note with a registered NoteOff (anti hanging note). */
1832
+ midiNote(midi, durationSec, velocity) {
1833
+ if (this.midiOutput === null) return;
1834
+ const note = midi & 127;
1835
+ const vel = Math.max(1, Math.min(127, Math.floor(velocity * 127)));
1836
+ try {
1603
1837
  this.midiOutput.send([
1604
1838
  144,
1605
- midi & 127,
1839
+ note,
1606
1840
  vel
1607
1841
  ]);
1608
- setTimeout(() => {
1842
+ } catch {
1843
+ return;
1844
+ }
1845
+ this.activeMidiNotes.add(note);
1846
+ setTimeout(() => {
1847
+ try {
1609
1848
  this.midiOutput?.send([
1610
1849
  128,
1611
- midi & 127,
1850
+ note,
1612
1851
  0
1613
1852
  ]);
1614
- }, durationSec * 1e3);
1615
- }
1853
+ } catch {}
1854
+ this.activeMidiNotes.delete(note);
1855
+ }, Math.max(80, durationSec * 1e3));
1616
1856
  }
1617
- /** Play a three-note chord plus a bass octave below. */
1618
- playChord(rootMidi, quality, durationSec, velocity) {
1619
- const intervals = chordIntervals(quality);
1620
- for (const interval of intervals) this.playNote(rootMidi + interval, durationSec, velocity * .45);
1621
- this.playNote(rootMidi - 12, durationSec * 1.1, velocity * .5);
1857
+ /** Silence every registered note and send All Notes Off. */
1858
+ allMidiOff() {
1859
+ if (this.midiOutput === null) return;
1860
+ for (const note of this.activeMidiNotes) try {
1861
+ this.midiOutput.send([
1862
+ 128,
1863
+ note,
1864
+ 0
1865
+ ]);
1866
+ } catch {}
1867
+ this.activeMidiNotes.clear();
1868
+ try {
1869
+ this.midiOutput.send([
1870
+ 176,
1871
+ 123,
1872
+ 0
1873
+ ]);
1874
+ } catch {}
1622
1875
  }
1623
1876
  };
1624
1877
  //#endregion
@@ -1697,7 +1950,7 @@ window.__ModuleLoader__.load({
1697
1950
  musicStyle: "风格",
1698
1951
  musicStyleMajor: "大调",
1699
1952
  musicStyleMinor: "小调",
1700
- musicStylePentatonic: "五声",
1953
+ musicStylePentatonic: "古风",
1701
1954
  musicOnlyCurrentSessionLabel: "只播当前会话",
1702
1955
  musicOnlyCurrentSessionHint: "开启后仅当前窗口的会话有音乐,后台子任务静音;关闭则所有会话都参与作曲",
1703
1956
  checkUpdatesNow: "立即检查",
@@ -1773,7 +2026,7 @@ window.__ModuleLoader__.load({
1773
2026
  musicStyle: "Style",
1774
2027
  musicStyleMajor: "Major",
1775
2028
  musicStyleMinor: "Minor",
1776
- musicStylePentatonic: "Pentatonic",
2029
+ musicStylePentatonic: "Gufeng",
1777
2030
  musicOnlyCurrentSessionLabel: "Current session only",
1778
2031
  musicOnlyCurrentSessionHint: "When on, only the visible session drives the soundtrack (background tasks stay silent); when off, every session contributes.",
1779
2032
  checkUpdatesNow: "Check now",
@@ -1823,6 +2076,12 @@ window.__ModuleLoader__.load({
1823
2076
  let lastOnlyCurrentSession = true;
1824
2077
  const music = new TokenQuotaMusic();
1825
2078
  const sse = new EventSource("/token-quota/events");
2079
+ sse.addEventListener("hb", () => {
2080
+ music.markStreamActivity();
2081
+ });
2082
+ sse.addEventListener("open", () => {
2083
+ music.markStreamActivity();
2084
+ });
1826
2085
  sse.addEventListener("action", (event) => {
1827
2086
  let action;
1828
2087
  try {
@@ -1840,7 +2099,7 @@ window.__ModuleLoader__.load({
1840
2099
  const musicDefaults = (raw) => ({
1841
2100
  enabled: raw?.enabled ?? false,
1842
2101
  volume: raw?.volume ?? .5,
1843
- style: raw?.style ?? "major",
2102
+ style: raw?.style ?? "pentatonic",
1844
2103
  onlyCurrentSession: raw?.onlyCurrentSession ?? true
1845
2104
  });
1846
2105
  const keyOf = (selection) => {