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