@sriinnu/harmon-core 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +661 -0
- package/README.md +45 -0
- package/SKILL.md +47 -0
- package/dist/arc.d.ts +16 -0
- package/dist/arc.d.ts.map +1 -0
- package/dist/arc.js +79 -0
- package/dist/arc.js.map +1 -0
- package/dist/engine.d.ts +28 -0
- package/dist/engine.d.ts.map +1 -0
- package/dist/engine.js +285 -0
- package/dist/engine.js.map +1 -0
- package/dist/history.d.ts +33 -0
- package/dist/history.d.ts.map +1 -0
- package/dist/history.js +81 -0
- package/dist/history.js.map +1 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +9 -0
- package/dist/index.js.map +1 -0
- package/dist/ranking.d.ts +18 -0
- package/dist/ranking.d.ts.map +1 -0
- package/dist/ranking.js +143 -0
- package/dist/ranking.js.map +1 -0
- package/dist/sources.d.ts +14 -0
- package/dist/sources.d.ts.map +1 -0
- package/dist/sources.js +186 -0
- package/dist/sources.js.map +1 -0
- package/dist/types.d.ts +160 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +5 -0
- package/dist/types.js.map +1 -0
- package/logo.svg +14 -0
- package/package.json +47 -0
package/README.md
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# @sriinnu/harmon-core
|
|
2
|
+
|
|
3
|
+

|
|
4
|
+
|
|
5
|
+
> Mood-aware session engine with track ranking, energy arc modulation, and adaptive playback.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pnpm add @sriinnu/harmon-core
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Quick Start
|
|
14
|
+
|
|
15
|
+
```typescript
|
|
16
|
+
import { createEngine } from '@sriinnu/harmon-core';
|
|
17
|
+
|
|
18
|
+
const engine = createEngine({ provider, playback, store });
|
|
19
|
+
await engine.start({ version: 1, mode: 'focus' });
|
|
20
|
+
engine.on('track.started', (track) => console.log(track.name));
|
|
21
|
+
await engine.stop();
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## API
|
|
25
|
+
|
|
26
|
+
| Export | Description |
|
|
27
|
+
|---|---|
|
|
28
|
+
| `createEngine(config)` | Create a session engine instance |
|
|
29
|
+
| `rankTracks(tracks, policy)` | Score and sort tracks against a session policy |
|
|
30
|
+
| `fetchCandidates(sources)` | Gather candidate tracks from configured providers |
|
|
31
|
+
| `calculateArcModulation(elapsed, arc)` | Compute energy multiplier for current position in arc |
|
|
32
|
+
| `checkRecencyPenalty(track, history)` | Penalize recently played tracks/artists |
|
|
33
|
+
| `SessionEngine` | Engine instance type |
|
|
34
|
+
| `EngineConfig` | Configuration type |
|
|
35
|
+
| `AudioFeatures` | Spotify-style audio feature vector |
|
|
36
|
+
| `SessionState` | Current engine state |
|
|
37
|
+
| `RankedTrack` | Track with computed score |
|
|
38
|
+
|
|
39
|
+
## Architecture
|
|
40
|
+
|
|
41
|
+
harmon-core is the central orchestrator. It consumes a `SessionPolicy` from harmon-protocol, pulls candidates from provider packages (harmon-spotify, harmon-apple, harmon-youtube), ranks them against soft/hard constraints, and drives playback through the active provider's controller.
|
|
42
|
+
|
|
43
|
+
## License
|
|
44
|
+
|
|
45
|
+
GNU Affero General Public License v3.0 only. See [LICENSE](../../LICENSE).
|
package/SKILL.md
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: harmon-core
|
|
3
|
+
description: Session engine with track ranking, candidate sourcing, and energy arc modulation
|
|
4
|
+
capabilities:
|
|
5
|
+
- Create and manage music listening sessions with policy-driven behavior
|
|
6
|
+
- Rank candidate tracks using soft weights, hard constraints, and recency penalties
|
|
7
|
+
- Fetch candidates from multiple music providers and blend discovery ratios
|
|
8
|
+
tags:
|
|
9
|
+
- engine
|
|
10
|
+
- ranking
|
|
11
|
+
- session
|
|
12
|
+
- music
|
|
13
|
+
provider: harmon
|
|
14
|
+
version: 0.1.0
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
# Harmon Core
|
|
18
|
+
|
|
19
|
+
## What this does
|
|
20
|
+
harmon-core is the decision-making engine at the heart of harmon. It accepts a SessionPolicy, sources candidate tracks from one or more MusicProvider adapters, ranks them according to soft weights, energy arcs, and hard constraints, and drives playback through a PlaybackController. It also tracks play history to apply recency penalties and prevent repetition.
|
|
21
|
+
|
|
22
|
+
## When to use
|
|
23
|
+
- Building a new session orchestrator or daemon that needs track selection logic
|
|
24
|
+
- Integrating a new music provider that must participate in ranking and queue filling
|
|
25
|
+
- Customizing how tracks are scored, filtered, or ordered during a session
|
|
26
|
+
|
|
27
|
+
## Key exports
|
|
28
|
+
- `createEngine` — factory that wires providers, store, and policy into a running SessionEngine
|
|
29
|
+
- `MusicProvider` — interface for any streaming backend (Spotify, Apple, YouTube, etc.)
|
|
30
|
+
- `PlaybackController` — interface for play/pause/skip/seek on a device
|
|
31
|
+
- `AudioFeatures` — normalized audio feature vector (energy, tempo, valence, etc.)
|
|
32
|
+
- `rankTracks` — pure function that scores and sorts a list of TrackWithFeatures
|
|
33
|
+
- `fetchCandidates` — pulls tracks from configured sources and deduplicates them
|
|
34
|
+
|
|
35
|
+
## Example
|
|
36
|
+
```typescript
|
|
37
|
+
import { createEngine } from '@sriinnu/harmon-core';
|
|
38
|
+
|
|
39
|
+
const engine = createEngine({
|
|
40
|
+
providers: [spotifyProvider],
|
|
41
|
+
playback: spotifyPlayback,
|
|
42
|
+
store,
|
|
43
|
+
policy: { version: 1, mode: 'focus' },
|
|
44
|
+
});
|
|
45
|
+
engine.on('track.started', (e) => console.log(e));
|
|
46
|
+
await engine.start();
|
|
47
|
+
```
|
package/dist/arc.d.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Energy arc modulation for session progression
|
|
3
|
+
*/
|
|
4
|
+
import type { EnergyArc } from '@sriinnu/harmon-protocol';
|
|
5
|
+
/**
|
|
6
|
+
* Calculate energy arc modulation based on session progress
|
|
7
|
+
*
|
|
8
|
+
* Returns: -0.5 to +0.5 modifier to apply to target energy
|
|
9
|
+
*
|
|
10
|
+
* @param elapsedMs Milliseconds elapsed since session start
|
|
11
|
+
* @param arc Energy arc configuration
|
|
12
|
+
* @param durationMs Total session duration (optional)
|
|
13
|
+
* @returns Energy modulation value
|
|
14
|
+
*/
|
|
15
|
+
export declare function calculateArcModulation(elapsedMs: number, arc?: EnergyArc, durationMs?: number): number;
|
|
16
|
+
//# sourceMappingURL=arc.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"arc.d.ts","sourceRoot":"","sources":["../src/arc.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,0BAA0B,CAAC;AAE1D;;;;;;;;;GASG;AACH,wBAAgB,sBAAsB,CACpC,SAAS,EAAE,MAAM,EACjB,GAAG,CAAC,EAAE,SAAS,EACf,UAAU,CAAC,EAAE,MAAM,GAClB,MAAM,CAsER"}
|
package/dist/arc.js
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Energy arc modulation for session progression
|
|
3
|
+
*/
|
|
4
|
+
/**
|
|
5
|
+
* Calculate energy arc modulation based on session progress
|
|
6
|
+
*
|
|
7
|
+
* Returns: -0.5 to +0.5 modifier to apply to target energy
|
|
8
|
+
*
|
|
9
|
+
* @param elapsedMs Milliseconds elapsed since session start
|
|
10
|
+
* @param arc Energy arc configuration
|
|
11
|
+
* @param durationMs Total session duration (optional)
|
|
12
|
+
* @returns Energy modulation value
|
|
13
|
+
*/
|
|
14
|
+
export function calculateArcModulation(elapsedMs, arc, durationMs) {
|
|
15
|
+
if (!arc || !arc.shape || arc.shape === 'flat') {
|
|
16
|
+
return 0;
|
|
17
|
+
}
|
|
18
|
+
const totalDuration = durationMs || 3600000; // Default 1 hour
|
|
19
|
+
const progress = Math.min(elapsedMs / totalDuration, 1); // 0-1
|
|
20
|
+
const warmupMs = arc.warmupMs || 0;
|
|
21
|
+
const cooldownMs = arc.cooldownMs || 0;
|
|
22
|
+
const warmupProgress = warmupMs > 0 ? Math.min(elapsedMs / warmupMs, 1) : 1;
|
|
23
|
+
const cooldownProgress = cooldownMs > 0 && durationMs
|
|
24
|
+
? Math.max(0, (elapsedMs - (durationMs - cooldownMs)) / cooldownMs)
|
|
25
|
+
: 0;
|
|
26
|
+
switch (arc.shape) {
|
|
27
|
+
case 'ramp-up': {
|
|
28
|
+
// Start low (-0.3), end high (+0.3)
|
|
29
|
+
if (warmupProgress < 1) {
|
|
30
|
+
return -0.3 * (1 - warmupProgress); // Warming up: -0.3 → 0
|
|
31
|
+
}
|
|
32
|
+
// Post-warmup: use adjusted progress for continuity
|
|
33
|
+
// At warmup end, this yields 0. At session end, yields +0.3.
|
|
34
|
+
const postWarmupProgress = warmupMs > 0
|
|
35
|
+
? Math.min((elapsedMs - warmupMs) / Math.max(totalDuration - warmupMs, 1), 1)
|
|
36
|
+
: progress;
|
|
37
|
+
// Without warmup: -0.3 + 0.6*progress (full range -0.3 to +0.3)
|
|
38
|
+
// With warmup: 0 + 0.3*postProgress (0 to +0.3, continuous from warmup end)
|
|
39
|
+
if (warmupMs > 0) {
|
|
40
|
+
return 0.3 * postWarmupProgress;
|
|
41
|
+
}
|
|
42
|
+
return -0.3 + (0.6 * progress);
|
|
43
|
+
}
|
|
44
|
+
case 'ramp-down': {
|
|
45
|
+
// Start high (+0.3), end low (-0.3)
|
|
46
|
+
if (warmupProgress < 1) {
|
|
47
|
+
return 0.3 * warmupProgress; // Warming up: 0 → +0.3
|
|
48
|
+
}
|
|
49
|
+
if (cooldownProgress > 0) {
|
|
50
|
+
// During cooldown, ramp to negative
|
|
51
|
+
const preCooldownValue = warmupMs > 0
|
|
52
|
+
? (() => {
|
|
53
|
+
const postProg = Math.min((durationMs - cooldownMs - warmupMs) / Math.max(totalDuration - warmupMs, 1), 1);
|
|
54
|
+
return 0.3 - (0.6 * postProg);
|
|
55
|
+
})()
|
|
56
|
+
: 0.3 - (0.6 * ((durationMs - cooldownMs) / totalDuration));
|
|
57
|
+
return preCooldownValue + ((-0.3 - preCooldownValue) * cooldownProgress);
|
|
58
|
+
}
|
|
59
|
+
// Post-warmup: linear ramp from +0.3 to -0.3
|
|
60
|
+
if (warmupMs > 0) {
|
|
61
|
+
const postWarmupProgress = Math.min((elapsedMs - warmupMs) / Math.max(totalDuration - warmupMs, 1), 1);
|
|
62
|
+
return 0.3 - (0.6 * postWarmupProgress);
|
|
63
|
+
}
|
|
64
|
+
return 0.3 - (0.6 * progress);
|
|
65
|
+
}
|
|
66
|
+
case 'wave':
|
|
67
|
+
// Sine wave: low -> high -> low
|
|
68
|
+
if (warmupProgress < 1) {
|
|
69
|
+
return -0.3 * (1 - warmupProgress);
|
|
70
|
+
}
|
|
71
|
+
if (cooldownProgress > 0) {
|
|
72
|
+
return -0.3 * cooldownProgress;
|
|
73
|
+
}
|
|
74
|
+
return 0.3 * Math.sin(progress * Math.PI); // Peak at middle
|
|
75
|
+
default:
|
|
76
|
+
return 0;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
//# sourceMappingURL=arc.js.map
|
package/dist/arc.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"arc.js","sourceRoot":"","sources":["../src/arc.ts"],"names":[],"mappings":"AAAA;;GAEG;AAIH;;;;;;;;;GASG;AACH,MAAM,UAAU,sBAAsB,CACpC,SAAiB,EACjB,GAAe,EACf,UAAmB;IAEnB,IAAI,CAAC,GAAG,IAAI,CAAC,GAAG,CAAC,KAAK,IAAI,GAAG,CAAC,KAAK,KAAK,MAAM,EAAE,CAAC;QAC/C,OAAO,CAAC,CAAC;IACX,CAAC;IAED,MAAM,aAAa,GAAG,UAAU,IAAI,OAAO,CAAC,CAAE,iBAAiB;IAC/D,MAAM,QAAQ,GAAG,IAAI,CAAC,GAAG,CAAC,SAAS,GAAG,aAAa,EAAE,CAAC,CAAC,CAAC,CAAE,MAAM;IAEhE,MAAM,QAAQ,GAAG,GAAG,CAAC,QAAQ,IAAI,CAAC,CAAC;IACnC,MAAM,UAAU,GAAG,GAAG,CAAC,UAAU,IAAI,CAAC,CAAC;IACvC,MAAM,cAAc,GAAG,QAAQ,GAAG,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,SAAS,GAAG,QAAQ,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;IAC5E,MAAM,gBAAgB,GAAG,UAAU,GAAG,CAAC,IAAI,UAAU;QACnD,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,CAAC,SAAS,GAAG,CAAC,UAAU,GAAG,UAAU,CAAC,CAAC,GAAG,UAAU,CAAC;QACnE,CAAC,CAAC,CAAC,CAAC;IAEN,QAAQ,GAAG,CAAC,KAAK,EAAE,CAAC;QAClB,KAAK,SAAS,CAAC,CAAC,CAAC;YACf,oCAAoC;YACpC,IAAI,cAAc,GAAG,CAAC,EAAE,CAAC;gBACvB,OAAO,CAAC,GAAG,GAAG,CAAC,CAAC,GAAG,cAAc,CAAC,CAAC,CAAE,uBAAuB;YAC9D,CAAC;YACD,oDAAoD;YACpD,6DAA6D;YAC7D,MAAM,kBAAkB,GAAG,QAAQ,GAAG,CAAC;gBACrC,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,SAAS,GAAG,QAAQ,CAAC,GAAG,IAAI,CAAC,GAAG,CAAC,aAAa,GAAG,QAAQ,EAAE,CAAC,CAAC,EAAE,CAAC,CAAC;gBAC7E,CAAC,CAAC,QAAQ,CAAC;YACb,gEAAgE;YAChE,4EAA4E;YAC5E,IAAI,QAAQ,GAAG,CAAC,EAAE,CAAC;gBACjB,OAAO,GAAG,GAAG,kBAAkB,CAAC;YAClC,CAAC;YACD,OAAO,CAAC,GAAG,GAAG,CAAC,GAAG,GAAG,QAAQ,CAAC,CAAC;QACjC,CAAC;QAED,KAAK,WAAW,CAAC,CAAC,CAAC;YACjB,oCAAoC;YACpC,IAAI,cAAc,GAAG,CAAC,EAAE,CAAC;gBACvB,OAAO,GAAG,GAAG,cAAc,CAAC,CAAE,uBAAuB;YACvD,CAAC;YACD,IAAI,gBAAgB,GAAG,CAAC,EAAE,CAAC;gBACzB,oCAAoC;gBACpC,MAAM,gBAAgB,GAAG,QAAQ,GAAG,CAAC;oBACnC,CAAC,CAAC,CAAC,GAAG,EAAE;wBACJ,MAAM,QAAQ,GAAG,IAAI,CAAC,GAAG,CAAC,CAAC,UAAW,GAAG,UAAU,GAAG,QAAQ,CAAC,GAAG,IAAI,CAAC,GAAG,CAAC,aAAa,GAAG,QAAQ,EAAE,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC;wBAC5G,OAAO,GAAG,GAAG,CAAC,GAAG,GAAG,QAAQ,CAAC,CAAC;oBAChC,CAAC,CAAC,EAAE;oBACN,CAAC,CAAC,GAAG,GAAG,CAAC,GAAG,GAAG,CAAC,CAAC,UAAW,GAAG,UAAU,CAAC,GAAG,aAAa,CAAC,CAAC,CAAC;gBAC/D,OAAO,gBAAgB,GAAG,CAAC,CAAC,CAAC,GAAG,GAAG,gBAAgB,CAAC,GAAG,gBAAgB,CAAC,CAAC;YAC3E,CAAC;YACD,6CAA6C;YAC7C,IAAI,QAAQ,GAAG,CAAC,EAAE,CAAC;gBACjB,MAAM,kBAAkB,GAAG,IAAI,CAAC,GAAG,CAAC,CAAC,SAAS,GAAG,QAAQ,CAAC,GAAG,IAAI,CAAC,GAAG,CAAC,aAAa,GAAG,QAAQ,EAAE,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC;gBACvG,OAAO,GAAG,GAAG,CAAC,GAAG,GAAG,kBAAkB,CAAC,CAAC;YAC1C,CAAC;YACD,OAAO,GAAG,GAAG,CAAC,GAAG,GAAG,QAAQ,CAAC,CAAC;QAChC,CAAC;QAED,KAAK,MAAM;YACT,gCAAgC;YAChC,IAAI,cAAc,GAAG,CAAC,EAAE,CAAC;gBACvB,OAAO,CAAC,GAAG,GAAG,CAAC,CAAC,GAAG,cAAc,CAAC,CAAC;YACrC,CAAC;YACD,IAAI,gBAAgB,GAAG,CAAC,EAAE,CAAC;gBACzB,OAAO,CAAC,GAAG,GAAG,gBAAgB,CAAC;YACjC,CAAC;YACD,OAAO,GAAG,GAAG,IAAI,CAAC,GAAG,CAAC,QAAQ,GAAG,IAAI,CAAC,EAAE,CAAC,CAAC,CAAE,iBAAiB;QAE/D;YACE,OAAO,CAAC,CAAC;IACb,CAAC;AACH,CAAC"}
|
package/dist/engine.d.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session engine - Core orchestrator for session lifecycle and queue management
|
|
3
|
+
*
|
|
4
|
+
* Provider-agnostic: works with any MusicProvider + PlaybackController.
|
|
5
|
+
*/
|
|
6
|
+
import type { SessionPolicy, TrackInfo } from '@sriinnu/harmon-protocol';
|
|
7
|
+
import type { MusicProvider, PlaybackController, SessionState, EventCallback, EngineLogger, SessionStore } from './types.js';
|
|
8
|
+
export interface SessionEngine {
|
|
9
|
+
start(policy: SessionPolicy): Promise<void>;
|
|
10
|
+
stop(): Promise<void>;
|
|
11
|
+
pause(): Promise<void>;
|
|
12
|
+
resume(): Promise<void>;
|
|
13
|
+
nudge(direction: 'calmer' | 'sharper', amount?: number): Promise<void>;
|
|
14
|
+
getQueue(): TrackInfo[];
|
|
15
|
+
getState(): SessionState | null;
|
|
16
|
+
refillQueue(): Promise<void>;
|
|
17
|
+
recordPlay(track: TrackInfo): Promise<void>;
|
|
18
|
+
}
|
|
19
|
+
export interface EngineConfig {
|
|
20
|
+
provider: MusicProvider;
|
|
21
|
+
playback: PlaybackController;
|
|
22
|
+
store: SessionStore;
|
|
23
|
+
onEvent?: EventCallback;
|
|
24
|
+
/** Optional logger for engine internals. Falls back to no-op if omitted. */
|
|
25
|
+
logger?: EngineLogger;
|
|
26
|
+
}
|
|
27
|
+
export declare function createEngine(config: EngineConfig): SessionEngine;
|
|
28
|
+
//# sourceMappingURL=engine.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"engine.d.ts","sourceRoot":"","sources":["../src/engine.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,KAAK,EAAE,aAAa,EAAE,SAAS,EAAE,MAAM,0BAA0B,CAAC;AACzE,OAAO,KAAK,EACV,aAAa,EACb,kBAAkB,EAClB,YAAY,EACZ,aAAa,EAEb,YAAY,EAEZ,YAAY,EACb,MAAM,YAAY,CAAC;AAIpB,MAAM,WAAW,aAAa;IAC5B,KAAK,CAAC,MAAM,EAAE,aAAa,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC5C,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;IACtB,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;IACvB,MAAM,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;IACxB,KAAK,CAAC,SAAS,EAAE,QAAQ,GAAG,SAAS,EAAE,MAAM,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACvE,QAAQ,IAAI,SAAS,EAAE,CAAC;IACxB,QAAQ,IAAI,YAAY,GAAG,IAAI,CAAC;IAChC,WAAW,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;IAC7B,UAAU,CAAC,KAAK,EAAE,SAAS,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CAC7C;AAED,MAAM,WAAW,YAAY;IAC3B,QAAQ,EAAE,aAAa,CAAC;IACxB,QAAQ,EAAE,kBAAkB,CAAC;IAC7B,KAAK,EAAE,YAAY,CAAC;IACpB,OAAO,CAAC,EAAE,aAAa,CAAC;IACxB,4EAA4E;IAC5E,MAAM,CAAC,EAAE,YAAY,CAAC;CACvB;AA+VD,wBAAgB,YAAY,CAAC,MAAM,EAAE,YAAY,GAAG,aAAa,CAEhE"}
|
package/dist/engine.js
ADDED
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session engine - Core orchestrator for session lifecycle and queue management
|
|
3
|
+
*
|
|
4
|
+
* Provider-agnostic: works with any MusicProvider + PlaybackController.
|
|
5
|
+
*/
|
|
6
|
+
import { fetchCandidates } from './sources.js';
|
|
7
|
+
import { rankTracks } from './ranking.js';
|
|
8
|
+
class SessionEngineImpl {
|
|
9
|
+
provider;
|
|
10
|
+
playback;
|
|
11
|
+
store;
|
|
12
|
+
onEvent;
|
|
13
|
+
logger;
|
|
14
|
+
state = null;
|
|
15
|
+
refillInterval = null;
|
|
16
|
+
refilling = false;
|
|
17
|
+
// Constants
|
|
18
|
+
REFILL_CHECK_INTERVAL_MS = 10000; // Check every 10s
|
|
19
|
+
constructor(config) {
|
|
20
|
+
this.provider = config.provider;
|
|
21
|
+
this.playback = config.playback;
|
|
22
|
+
this.store = config.store;
|
|
23
|
+
this.onEvent = config.onEvent || (() => { });
|
|
24
|
+
this.logger = config.logger || { warn: () => { }, error: () => { } };
|
|
25
|
+
}
|
|
26
|
+
async start(policy) {
|
|
27
|
+
if (this.state !== null) {
|
|
28
|
+
throw new Error('Session already active. Stop current session first.');
|
|
29
|
+
}
|
|
30
|
+
// Create session in store
|
|
31
|
+
const sessionId = await this.store.createSession(JSON.stringify(policy));
|
|
32
|
+
// Initialize state
|
|
33
|
+
this.state = {
|
|
34
|
+
id: sessionId,
|
|
35
|
+
policy,
|
|
36
|
+
startedAt: Date.now(),
|
|
37
|
+
status: 'running',
|
|
38
|
+
history: [],
|
|
39
|
+
currentTrack: null,
|
|
40
|
+
queuedTracks: [],
|
|
41
|
+
};
|
|
42
|
+
// Initial queue fill
|
|
43
|
+
await this.refillQueue();
|
|
44
|
+
// Start auto-refill monitoring
|
|
45
|
+
this.startRefillMonitoring();
|
|
46
|
+
// Emit event
|
|
47
|
+
this.emit({
|
|
48
|
+
type: 'session.started',
|
|
49
|
+
payload: {
|
|
50
|
+
sessionId,
|
|
51
|
+
policy,
|
|
52
|
+
startedAt: this.state.startedAt,
|
|
53
|
+
},
|
|
54
|
+
});
|
|
55
|
+
await this.store.logEvent('session.started', { sessionId, policy }, sessionId);
|
|
56
|
+
}
|
|
57
|
+
async stop() {
|
|
58
|
+
if (!this.state) {
|
|
59
|
+
throw new Error('No active session');
|
|
60
|
+
}
|
|
61
|
+
const stoppedState = this.state;
|
|
62
|
+
const elapsedMs = Date.now() - stoppedState.startedAt;
|
|
63
|
+
// Stop monitoring
|
|
64
|
+
this.stopRefillMonitoring();
|
|
65
|
+
// Clear the live session before store finalization so a persistence error
|
|
66
|
+
// cannot strand a ghost in-memory session.
|
|
67
|
+
this.state = null;
|
|
68
|
+
// End session in store
|
|
69
|
+
await this.store.endSession(stoppedState.id);
|
|
70
|
+
await this.store.logEvent('session.stopped', { sessionId: stoppedState.id }, stoppedState.id);
|
|
71
|
+
// Emit event
|
|
72
|
+
this.emit({
|
|
73
|
+
type: 'session.stopped',
|
|
74
|
+
payload: {
|
|
75
|
+
sessionId: stoppedState.id,
|
|
76
|
+
elapsedMs,
|
|
77
|
+
duration: elapsedMs,
|
|
78
|
+
durationMs: elapsedMs,
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
async pause() {
|
|
83
|
+
if (!this.state) {
|
|
84
|
+
throw new Error('No active session');
|
|
85
|
+
}
|
|
86
|
+
this.state.status = 'paused';
|
|
87
|
+
this.stopRefillMonitoring();
|
|
88
|
+
}
|
|
89
|
+
async resume() {
|
|
90
|
+
if (!this.state) {
|
|
91
|
+
throw new Error('No active session');
|
|
92
|
+
}
|
|
93
|
+
this.state.status = 'running';
|
|
94
|
+
this.startRefillMonitoring();
|
|
95
|
+
}
|
|
96
|
+
async nudge(direction, amount = 0.1) {
|
|
97
|
+
if (!this.state) {
|
|
98
|
+
throw new Error('No active session');
|
|
99
|
+
}
|
|
100
|
+
const sign = direction === 'calmer' ? -1 : 1;
|
|
101
|
+
const policy = this.state.policy;
|
|
102
|
+
const previousPolicy = policy;
|
|
103
|
+
const previousQueue = [...this.state.queuedTracks];
|
|
104
|
+
// Update soft weights
|
|
105
|
+
const currentWeights = policy.soft?.weights || {};
|
|
106
|
+
const newWeights = { ...currentWeights };
|
|
107
|
+
// Adjust energy and valence — clamp to protocol range [-1, 1]
|
|
108
|
+
if (typeof newWeights.energy === 'number') {
|
|
109
|
+
newWeights.energy = clamp(newWeights.energy + sign * amount, -1, 1);
|
|
110
|
+
}
|
|
111
|
+
else {
|
|
112
|
+
newWeights.energy = clamp(0.5 + sign * amount, -1, 1);
|
|
113
|
+
}
|
|
114
|
+
if (typeof newWeights.valence === 'number') {
|
|
115
|
+
newWeights.valence = clamp(newWeights.valence + sign * amount * 0.5, -1, 1);
|
|
116
|
+
}
|
|
117
|
+
else {
|
|
118
|
+
newWeights.valence = clamp(0.5 + sign * amount * 0.5, -1, 1);
|
|
119
|
+
}
|
|
120
|
+
// Update policy
|
|
121
|
+
this.state.policy = {
|
|
122
|
+
...policy,
|
|
123
|
+
soft: {
|
|
124
|
+
...policy.soft,
|
|
125
|
+
weights: newWeights,
|
|
126
|
+
},
|
|
127
|
+
};
|
|
128
|
+
// Clear queue and refill with new weights
|
|
129
|
+
this.state.queuedTracks = [];
|
|
130
|
+
await this.refillQueue();
|
|
131
|
+
if (this.state.queuedTracks.length === 0) {
|
|
132
|
+
this.state.policy = previousPolicy;
|
|
133
|
+
this.state.queuedTracks = previousQueue;
|
|
134
|
+
throw new Error('Nudge could not refill the queue with the updated session policy.');
|
|
135
|
+
}
|
|
136
|
+
await this.store.logEvent('session.nudged', { direction, amount, newWeights }, this.state.id);
|
|
137
|
+
this.emit({
|
|
138
|
+
type: 'session.nudged',
|
|
139
|
+
payload: {
|
|
140
|
+
sessionId: this.state.id,
|
|
141
|
+
direction,
|
|
142
|
+
amount,
|
|
143
|
+
newWeights,
|
|
144
|
+
},
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
getQueue() {
|
|
148
|
+
return this.state?.queuedTracks || [];
|
|
149
|
+
}
|
|
150
|
+
getState() {
|
|
151
|
+
return this.state;
|
|
152
|
+
}
|
|
153
|
+
async refillQueue() {
|
|
154
|
+
if (!this.state || this.refilling) {
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
this.refilling = true;
|
|
158
|
+
try {
|
|
159
|
+
const policy = this.state.policy;
|
|
160
|
+
const queuePrefs = policy.queue || {};
|
|
161
|
+
const targetDepth = queuePrefs.target || 12;
|
|
162
|
+
const refillThreshold = queuePrefs.refillWhenBelow || 5;
|
|
163
|
+
const currentDepth = this.state.queuedTracks.length;
|
|
164
|
+
if (currentDepth >= refillThreshold) {
|
|
165
|
+
return; // Queue still healthy
|
|
166
|
+
}
|
|
167
|
+
const needed = targetDepth - currentDepth;
|
|
168
|
+
// Fetch candidates via provider
|
|
169
|
+
const candidates = await fetchCandidates(this.provider, policy.sources || {}, needed * 3, // Fetch 3x needed for filtering
|
|
170
|
+
this.logger);
|
|
171
|
+
if (candidates.length === 0) {
|
|
172
|
+
this.emit({
|
|
173
|
+
type: 'error',
|
|
174
|
+
payload: { message: 'No candidates found for queue refill' },
|
|
175
|
+
});
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
// Rank tracks
|
|
179
|
+
const ranked = await rankTracks(candidates, policy, this.state.history, this.getElapsedMs());
|
|
180
|
+
// Take top N
|
|
181
|
+
const topTracks = ranked.slice(0, needed);
|
|
182
|
+
// Add to playback queue
|
|
183
|
+
for (const { track } of topTracks) {
|
|
184
|
+
if (track.uri) {
|
|
185
|
+
await this.playback.addToQueue(track.uri, track);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
// Update local queue state
|
|
189
|
+
this.state.queuedTracks.push(...topTracks.map(r => r.track));
|
|
190
|
+
// Emit event
|
|
191
|
+
this.emit({
|
|
192
|
+
type: 'queue.refilled',
|
|
193
|
+
payload: {
|
|
194
|
+
sessionId: this.state.id,
|
|
195
|
+
added: topTracks.length,
|
|
196
|
+
queueDepth: this.state.queuedTracks.length,
|
|
197
|
+
},
|
|
198
|
+
});
|
|
199
|
+
await this.store.logEvent('queue.refilled', { added: topTracks.length, queueDepth: this.state.queuedTracks.length }, this.state.id);
|
|
200
|
+
}
|
|
201
|
+
catch (error) {
|
|
202
|
+
this.emit({
|
|
203
|
+
type: 'error',
|
|
204
|
+
payload: {
|
|
205
|
+
message: error instanceof Error ? error.message : 'Queue refill failed',
|
|
206
|
+
},
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
finally {
|
|
210
|
+
this.refilling = false;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
// Track when a track finishes (called by daemon monitoring)
|
|
214
|
+
async recordPlay(track) {
|
|
215
|
+
if (!this.state) {
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
const record = {
|
|
219
|
+
trackId: track.id,
|
|
220
|
+
artistIds: track.artistIds && track.artistIds.length > 0
|
|
221
|
+
? track.artistIds
|
|
222
|
+
: [track.artist],
|
|
223
|
+
playedAt: Date.now(),
|
|
224
|
+
};
|
|
225
|
+
this.state.history.push(record);
|
|
226
|
+
// Trim history to last 48 hours to prevent unbounded memory growth.
|
|
227
|
+
// 48h covers the widest repetition limit (repeatTrackWithinDays: 1 = 24h)
|
|
228
|
+
// plus a generous safety margin.
|
|
229
|
+
const HISTORY_RETENTION_MS = 48 * 60 * 60 * 1000;
|
|
230
|
+
const cutoff = Date.now() - HISTORY_RETENTION_MS;
|
|
231
|
+
if (this.state.history.length > 100) {
|
|
232
|
+
this.state.history = this.state.history.filter(r => r.playedAt >= cutoff);
|
|
233
|
+
}
|
|
234
|
+
this.state.currentTrack = track;
|
|
235
|
+
// Remove from queue
|
|
236
|
+
this.state.queuedTracks = this.state.queuedTracks.filter(t => t.id !== track.id);
|
|
237
|
+
// Emit engine event so the daemon can broadcast to SSE clients
|
|
238
|
+
this.emit({
|
|
239
|
+
type: 'track.started',
|
|
240
|
+
payload: {
|
|
241
|
+
sessionId: this.state.id,
|
|
242
|
+
playedAt: record.playedAt,
|
|
243
|
+
track,
|
|
244
|
+
},
|
|
245
|
+
});
|
|
246
|
+
await this.store.logEvent('track.started', {
|
|
247
|
+
playedAt: record.playedAt,
|
|
248
|
+
track,
|
|
249
|
+
}, this.state.id);
|
|
250
|
+
}
|
|
251
|
+
startRefillMonitoring() {
|
|
252
|
+
if (this.refillInterval !== null) {
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
this.refillInterval = setInterval(() => {
|
|
256
|
+
this.refillQueue().catch(err => {
|
|
257
|
+
this.logger.error({ error: err instanceof Error ? err.message : String(err) }, 'Auto-refill failed');
|
|
258
|
+
});
|
|
259
|
+
}, this.REFILL_CHECK_INTERVAL_MS);
|
|
260
|
+
}
|
|
261
|
+
stopRefillMonitoring() {
|
|
262
|
+
if (this.refillInterval !== null) {
|
|
263
|
+
clearInterval(this.refillInterval);
|
|
264
|
+
this.refillInterval = null;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
emit(event) {
|
|
268
|
+
try {
|
|
269
|
+
this.onEvent(event);
|
|
270
|
+
}
|
|
271
|
+
catch (error) {
|
|
272
|
+
this.logger.error({ error: error instanceof Error ? error.message : String(error) }, 'Event callback error');
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
getElapsedMs() {
|
|
276
|
+
return this.state ? Date.now() - this.state.startedAt : 0;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
function clamp(value, min, max) {
|
|
280
|
+
return Math.min(max, Math.max(min, value));
|
|
281
|
+
}
|
|
282
|
+
export function createEngine(config) {
|
|
283
|
+
return new SessionEngineImpl(config);
|
|
284
|
+
}
|
|
285
|
+
//# sourceMappingURL=engine.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"engine.js","sourceRoot":"","sources":["../src/engine.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAaH,OAAO,EAAE,eAAe,EAAE,MAAM,cAAc,CAAC;AAC/C,OAAO,EAAE,UAAU,EAAE,MAAM,cAAc,CAAC;AAuB1C,MAAM,iBAAiB;IACb,QAAQ,CAAgB;IACxB,QAAQ,CAAqB;IAC7B,KAAK,CAAe;IACpB,OAAO,CAAgB;IACvB,MAAM,CAAe;IACrB,KAAK,GAAwB,IAAI,CAAC;IAClC,cAAc,GAA0C,IAAI,CAAC;IAC7D,SAAS,GAAG,KAAK,CAAC;IAE1B,YAAY;IACK,wBAAwB,GAAG,KAAK,CAAC,CAAE,kBAAkB;IAEtE,YAAY,MAAoB;QAC9B,IAAI,CAAC,QAAQ,GAAG,MAAM,CAAC,QAAQ,CAAC;QAChC,IAAI,CAAC,QAAQ,GAAG,MAAM,CAAC,QAAQ,CAAC;QAChC,IAAI,CAAC,KAAK,GAAG,MAAM,CAAC,KAAK,CAAC;QAC1B,IAAI,CAAC,OAAO,GAAG,MAAM,CAAC,OAAO,IAAI,CAAC,GAAG,EAAE,GAAE,CAAC,CAAC,CAAC;QAC5C,IAAI,CAAC,MAAM,GAAG,MAAM,CAAC,MAAM,IAAI,EAAE,IAAI,EAAE,GAAG,EAAE,GAAE,CAAC,EAAE,KAAK,EAAE,GAAG,EAAE,GAAE,CAAC,EAAE,CAAC;IACrE,CAAC;IAED,KAAK,CAAC,KAAK,CAAC,MAAqB;QAC/B,IAAI,IAAI,CAAC,KAAK,KAAK,IAAI,EAAE,CAAC;YACxB,MAAM,IAAI,KAAK,CAAC,qDAAqD,CAAC,CAAC;QACzE,CAAC;QAED,0BAA0B;QAC1B,MAAM,SAAS,GAAG,MAAM,IAAI,CAAC,KAAK,CAAC,aAAa,CAAC,IAAI,CAAC,SAAS,CAAC,MAAM,CAAC,CAAC,CAAC;QAEzE,mBAAmB;QACnB,IAAI,CAAC,KAAK,GAAG;YACX,EAAE,EAAE,SAAS;YACb,MAAM;YACN,SAAS,EAAE,IAAI,CAAC,GAAG,EAAE;YACrB,MAAM,EAAE,SAAS;YACjB,OAAO,EAAE,EAAE;YACX,YAAY,EAAE,IAAI;YAClB,YAAY,EAAE,EAAE;SACjB,CAAC;QAEF,qBAAqB;QACrB,MAAM,IAAI,CAAC,WAAW,EAAE,CAAC;QAEzB,+BAA+B;QAC/B,IAAI,CAAC,qBAAqB,EAAE,CAAC;QAE7B,aAAa;QACb,IAAI,CAAC,IAAI,CAAC;YACR,IAAI,EAAE,iBAAiB;YACvB,OAAO,EAAE;gBACP,SAAS;gBACT,MAAM;gBACN,SAAS,EAAE,IAAI,CAAC,KAAK,CAAC,SAAS;aAChC;SACF,CAAC,CAAC;QAEH,MAAM,IAAI,CAAC,KAAK,CAAC,QAAQ,CAAC,iBAAiB,EAAE,EAAE,SAAS,EAAE,MAAM,EAAE,EAAE,SAAS,CAAC,CAAC;IACjF,CAAC;IAED,KAAK,CAAC,IAAI;QACR,IAAI,CAAC,IAAI,CAAC,KAAK,EAAE,CAAC;YAChB,MAAM,IAAI,KAAK,CAAC,mBAAmB,CAAC,CAAC;QACvC,CAAC;QAED,MAAM,YAAY,GAAG,IAAI,CAAC,KAAK,CAAC;QAChC,MAAM,SAAS,GAAG,IAAI,CAAC,GAAG,EAAE,GAAG,YAAY,CAAC,SAAS,CAAC;QAEtD,kBAAkB;QAClB,IAAI,CAAC,oBAAoB,EAAE,CAAC;QAE5B,0EAA0E;QAC1E,2CAA2C;QAC3C,IAAI,CAAC,KAAK,GAAG,IAAI,CAAC;QAElB,uBAAuB;QACvB,MAAM,IAAI,CAAC,KAAK,CAAC,UAAU,CAAC,YAAY,CAAC,EAAE,CAAC,CAAC;QAC7C,MAAM,IAAI,CAAC,KAAK,CAAC,QAAQ,CAAC,iBAAiB,EAAE,EAAE,SAAS,EAAE,YAAY,CAAC,EAAE,EAAE,EAAE,YAAY,CAAC,EAAE,CAAC,CAAC;QAE9F,aAAa;QACb,IAAI,CAAC,IAAI,CAAC;YACR,IAAI,EAAE,iBAAiB;YACvB,OAAO,EAAE;gBACP,SAAS,EAAE,YAAY,CAAC,EAAE;gBAC1B,SAAS;gBACT,QAAQ,EAAE,SAAS;gBACnB,UAAU,EAAE,SAAS;aACtB;SACF,CAAC,CAAC;IACL,CAAC;IAED,KAAK,CAAC,KAAK;QACT,IAAI,CAAC,IAAI,CAAC,KAAK,EAAE,CAAC;YAChB,MAAM,IAAI,KAAK,CAAC,mBAAmB,CAAC,CAAC;QACvC,CAAC;QAED,IAAI,CAAC,KAAK,CAAC,MAAM,GAAG,QAAQ,CAAC;QAC7B,IAAI,CAAC,oBAAoB,EAAE,CAAC;IAC9B,CAAC;IAED,KAAK,CAAC,MAAM;QACV,IAAI,CAAC,IAAI,CAAC,KAAK,EAAE,CAAC;YAChB,MAAM,IAAI,KAAK,CAAC,mBAAmB,CAAC,CAAC;QACvC,CAAC;QAED,IAAI,CAAC,KAAK,CAAC,MAAM,GAAG,SAAS,CAAC;QAC9B,IAAI,CAAC,qBAAqB,EAAE,CAAC;IAC/B,CAAC;IAED,KAAK,CAAC,KAAK,CAAC,SAA+B,EAAE,MAAM,GAAG,GAAG;QACvD,IAAI,CAAC,IAAI,CAAC,KAAK,EAAE,CAAC;YAChB,MAAM,IAAI,KAAK,CAAC,mBAAmB,CAAC,CAAC;QACvC,CAAC;QAED,MAAM,IAAI,GAAG,SAAS,KAAK,QAAQ,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;QAC7C,MAAM,MAAM,GAAG,IAAI,CAAC,KAAK,CAAC,MAAM,CAAC;QACjC,MAAM,cAAc,GAAG,MAAM,CAAC;QAC9B,MAAM,aAAa,GAAG,CAAC,GAAG,IAAI,CAAC,KAAK,CAAC,YAAY,CAAC,CAAC;QAEnD,sBAAsB;QACtB,MAAM,cAAc,GAAG,MAAM,CAAC,IAAI,EAAE,OAAO,IAAI,EAAE,CAAC;QAClD,MAAM,UAAU,GAAG,EAAE,GAAG,cAAc,EAAE,CAAC;QAEzC,8DAA8D;QAC9D,IAAI,OAAO,UAAU,CAAC,MAAM,KAAK,QAAQ,EAAE,CAAC;YAC1C,UAAU,CAAC,MAAM,GAAG,KAAK,CAAC,UAAU,CAAC,MAAM,GAAG,IAAI,GAAG,MAAM,EAAE,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC;QACtE,CAAC;aAAM,CAAC;YACN,UAAU,CAAC,MAAM,GAAG,KAAK,CAAC,GAAG,GAAG,IAAI,GAAG,MAAM,EAAE,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC;QACxD,CAAC;QAED,IAAI,OAAO,UAAU,CAAC,OAAO,KAAK,QAAQ,EAAE,CAAC;YAC3C,UAAU,CAAC,OAAO,GAAG,KAAK,CAAC,UAAU,CAAC,OAAO,GAAG,IAAI,GAAG,MAAM,GAAG,GAAG,EAAE,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC;QAC9E,CAAC;aAAM,CAAC;YACN,UAAU,CAAC,OAAO,GAAG,KAAK,CAAC,GAAG,GAAG,IAAI,GAAG,MAAM,GAAG,GAAG,EAAE,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC;QAC/D,CAAC;QAED,gBAAgB;QAChB,IAAI,CAAC,KAAK,CAAC,MAAM,GAAG;YAClB,GAAG,MAAM;YACT,IAAI,EAAE;gBACJ,GAAG,MAAM,CAAC,IAAI;gBACd,OAAO,EAAE,UAAU;aACpB;SACF,CAAC;QAEF,0CAA0C;QAC1C,IAAI,CAAC,KAAK,CAAC,YAAY,GAAG,EAAE,CAAC;QAC7B,MAAM,IAAI,CAAC,WAAW,EAAE,CAAC;QAEzB,IAAI,IAAI,CAAC,KAAK,CAAC,YAAY,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;YACzC,IAAI,CAAC,KAAK,CAAC,MAAM,GAAG,cAAc,CAAC;YACnC,IAAI,CAAC,KAAK,CAAC,YAAY,GAAG,aAAa,CAAC;YACxC,MAAM,IAAI,KAAK,CAAC,mEAAmE,CAAC,CAAC;QACvF,CAAC;QAED,MAAM,IAAI,CAAC,KAAK,CAAC,QAAQ,CACvB,gBAAgB,EAChB,EAAE,SAAS,EAAE,MAAM,EAAE,UAAU,EAAE,EACjC,IAAI,CAAC,KAAK,CAAC,EAAE,CACd,CAAC;QAEF,IAAI,CAAC,IAAI,CAAC;YACR,IAAI,EAAE,gBAAgB;YACtB,OAAO,EAAE;gBACP,SAAS,EAAE,IAAI,CAAC,KAAK,CAAC,EAAE;gBACxB,SAAS;gBACT,MAAM;gBACN,UAAU;aACX;SACF,CAAC,CAAC;IACL,CAAC;IAED,QAAQ;QACN,OAAO,IAAI,CAAC,KAAK,EAAE,YAAY,IAAI,EAAE,CAAC;IACxC,CAAC;IAED,QAAQ;QACN,OAAO,IAAI,CAAC,KAAK,CAAC;IACpB,CAAC;IAED,KAAK,CAAC,WAAW;QACf,IAAI,CAAC,IAAI,CAAC,KAAK,IAAI,IAAI,CAAC,SAAS,EAAE,CAAC;YAClC,OAAO;QACT,CAAC;QAED,IAAI,CAAC,SAAS,GAAG,IAAI,CAAC;QACtB,IAAI,CAAC;YACH,MAAM,MAAM,GAAG,IAAI,CAAC,KAAK,CAAC,MAAM,CAAC;YACjC,MAAM,UAAU,GAAG,MAAM,CAAC,KAAK,IAAI,EAAE,CAAC;YACtC,MAAM,WAAW,GAAG,UAAU,CAAC,MAAM,IAAI,EAAE,CAAC;YAC5C,MAAM,eAAe,GAAG,UAAU,CAAC,eAAe,IAAI,CAAC,CAAC;YAExD,MAAM,YAAY,GAAG,IAAI,CAAC,KAAK,CAAC,YAAY,CAAC,MAAM,CAAC;YAEpD,IAAI,YAAY,IAAI,eAAe,EAAE,CAAC;gBACpC,OAAO,CAAE,sBAAsB;YACjC,CAAC;YAED,MAAM,MAAM,GAAG,WAAW,GAAG,YAAY,CAAC;YAE1C,gCAAgC;YAChC,MAAM,UAAU,GAAG,MAAM,eAAe,CACtC,IAAI,CAAC,QAAQ,EACb,MAAM,CAAC,OAAO,IAAI,EAAE,EACpB,MAAM,GAAG,CAAC,EAAG,gCAAgC;YAC7C,IAAI,CAAC,MAAM,CACZ,CAAC;YAEF,IAAI,UAAU,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;gBAC5B,IAAI,CAAC,IAAI,CAAC;oBACR,IAAI,EAAE,OAAO;oBACb,OAAO,EAAE,EAAE,OAAO,EAAE,sCAAsC,EAAE;iBAC7D,CAAC,CAAC;gBACH,OAAO;YACT,CAAC;YAED,cAAc;YACd,MAAM,MAAM,GAAG,MAAM,UAAU,CAC7B,UAAU,EACV,MAAM,EACN,IAAI,CAAC,KAAK,CAAC,OAAO,EAClB,IAAI,CAAC,YAAY,EAAE,CACpB,CAAC;YAEF,aAAa;YACb,MAAM,SAAS,GAAG,MAAM,CAAC,KAAK,CAAC,CAAC,EAAE,MAAM,CAAC,CAAC;YAE1C,wBAAwB;YACxB,KAAK,MAAM,EAAE,KAAK,EAAE,IAAI,SAAS,EAAE,CAAC;gBAClC,IAAI,KAAK,CAAC,GAAG,EAAE,CAAC;oBACd,MAAM,IAAI,CAAC,QAAQ,CAAC,UAAU,CAAC,KAAK,CAAC,GAAG,EAAE,KAAK,CAAC,CAAC;gBACnD,CAAC;YACH,CAAC;YAED,2BAA2B;YAC3B,IAAI,CAAC,KAAK,CAAC,YAAY,CAAC,IAAI,CAAC,GAAG,SAAS,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC;YAE7D,aAAa;YACb,IAAI,CAAC,IAAI,CAAC;gBACR,IAAI,EAAE,gBAAgB;gBACtB,OAAO,EAAE;oBACP,SAAS,EAAE,IAAI,CAAC,KAAK,CAAC,EAAE;oBACxB,KAAK,EAAE,SAAS,CAAC,MAAM;oBACvB,UAAU,EAAE,IAAI,CAAC,KAAK,CAAC,YAAY,CAAC,MAAM;iBAC3C;aACF,CAAC,CAAC;YAEH,MAAM,IAAI,CAAC,KAAK,CAAC,QAAQ,CACvB,gBAAgB,EAChB,EAAE,KAAK,EAAE,SAAS,CAAC,MAAM,EAAE,UAAU,EAAE,IAAI,CAAC,KAAK,CAAC,YAAY,CAAC,MAAM,EAAE,EACvE,IAAI,CAAC,KAAK,CAAC,EAAE,CACd,CAAC;QACJ,CAAC;QAAC,OAAO,KAAK,EAAE,CAAC;YACf,IAAI,CAAC,IAAI,CAAC;gBACR,IAAI,EAAE,OAAO;gBACb,OAAO,EAAE;oBACP,OAAO,EAAE,KAAK,YAAY,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,qBAAqB;iBACxE;aACF,CAAC,CAAC;QACL,CAAC;gBAAS,CAAC;YACT,IAAI,CAAC,SAAS,GAAG,KAAK,CAAC;QACzB,CAAC;IACH,CAAC;IAED,4DAA4D;IAC5D,KAAK,CAAC,UAAU,CAAC,KAAgB;QAC/B,IAAI,CAAC,IAAI,CAAC,KAAK,EAAE,CAAC;YAChB,OAAO;QACT,CAAC;QAED,MAAM,MAAM,GAAe;YACzB,OAAO,EAAE,KAAK,CAAC,EAAE;YACjB,SAAS,EAAE,KAAK,CAAC,SAAS,IAAI,KAAK,CAAC,SAAS,CAAC,MAAM,GAAG,CAAC;gBACtD,CAAC,CAAC,KAAK,CAAC,SAAS;gBACjB,CAAC,CAAC,CAAC,KAAK,CAAC,MAAM,CAAC;YAClB,QAAQ,EAAE,IAAI,CAAC,GAAG,EAAE;SACrB,CAAC;QAEF,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC;QAEhC,oEAAoE;QACpE,0EAA0E;QAC1E,iCAAiC;QACjC,MAAM,oBAAoB,GAAG,EAAE,GAAG,EAAE,GAAG,EAAE,GAAG,IAAI,CAAC;QACjD,MAAM,MAAM,GAAG,IAAI,CAAC,GAAG,EAAE,GAAG,oBAAoB,CAAC;QACjD,IAAI,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,MAAM,GAAG,GAAG,EAAE,CAAC;YACpC,IAAI,CAAC,KAAK,CAAC,OAAO,GAAG,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,QAAQ,IAAI,MAAM,CAAC,CAAC;QAC5E,CAAC;QAED,IAAI,CAAC,KAAK,CAAC,YAAY,GAAG,KAAK,CAAC;QAEhC,oBAAoB;QACpB,IAAI,CAAC,KAAK,CAAC,YAAY,GAAG,IAAI,CAAC,KAAK,CAAC,YAAY,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,KAAK,KAAK,CAAC,EAAE,CAAC,CAAC;QAEjF,+DAA+D;QAC/D,IAAI,CAAC,IAAI,CAAC;YACR,IAAI,EAAE,eAAe;YACrB,OAAO,EAAE;gBACP,SAAS,EAAE,IAAI,CAAC,KAAK,CAAC,EAAE;gBACxB,QAAQ,EAAE,MAAM,CAAC,QAAQ;gBACzB,KAAK;aACN;SACF,CAAC,CAAC;QAEH,MAAM,IAAI,CAAC,KAAK,CAAC,QAAQ,CACvB,eAAe,EACf;YACE,QAAQ,EAAE,MAAM,CAAC,QAAQ;YACzB,KAAK;SACN,EACD,IAAI,CAAC,KAAK,CAAC,EAAE,CACd,CAAC;IACJ,CAAC;IAEO,qBAAqB;QAC3B,IAAI,IAAI,CAAC,cAAc,KAAK,IAAI,EAAE,CAAC;YACjC,OAAO;QACT,CAAC;QAED,IAAI,CAAC,cAAc,GAAG,WAAW,CAAC,GAAG,EAAE;YACrC,IAAI,CAAC,WAAW,EAAE,CAAC,KAAK,CAAC,GAAG,CAAC,EAAE;gBAC7B,IAAI,CAAC,MAAM,CAAC,KAAK,CAAC,EAAE,KAAK,EAAE,GAAG,YAAY,KAAK,CAAC,CAAC,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,GAAG,CAAC,EAAE,EAAE,oBAAoB,CAAC,CAAC;YACvG,CAAC,CAAC,CAAC;QACL,CAAC,EAAE,IAAI,CAAC,wBAAwB,CAAC,CAAC;IACpC,CAAC;IAEO,oBAAoB;QAC1B,IAAI,IAAI,CAAC,cAAc,KAAK,IAAI,EAAE,CAAC;YACjC,aAAa,CAAC,IAAI,CAAC,cAAc,CAAC,CAAC;YACnC,IAAI,CAAC,cAAc,GAAG,IAAI,CAAC;QAC7B,CAAC;IACH,CAAC;IAEO,IAAI,CAAC,KAAkB;QAC7B,IAAI,CAAC;YACH,IAAI,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC;QACtB,CAAC;QAAC,OAAO,KAAK,EAAE,CAAC;YACf,IAAI,CAAC,MAAM,CAAC,KAAK,CAAC,EAAE,KAAK,EAAE,KAAK,YAAY,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,EAAE,EAAE,sBAAsB,CAAC,CAAC;QAC/G,CAAC;IACH,CAAC;IAEO,YAAY;QAClB,OAAO,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,IAAI,CAAC,GAAG,EAAE,GAAG,IAAI,CAAC,KAAK,CAAC,SAAS,CAAC,CAAC,CAAC,CAAC,CAAC;IAC5D,CAAC;CACF;AAED,SAAS,KAAK,CAAC,KAAa,EAAE,GAAW,EAAE,GAAW;IACpD,OAAO,IAAI,CAAC,GAAG,CAAC,GAAG,EAAE,IAAI,CAAC,GAAG,CAAC,GAAG,EAAE,KAAK,CAAC,CAAC,CAAC;AAC7C,CAAC;AAED,MAAM,UAAU,YAAY,CAAC,MAAoB;IAC/C,OAAO,IAAI,iBAAiB,CAAC,MAAM,CAAC,CAAC;AACvC,CAAC"}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Play history tracking and recency penalty calculation
|
|
3
|
+
*/
|
|
4
|
+
import type { RepetitionLimits } from '@sriinnu/harmon-protocol';
|
|
5
|
+
import type { TrackWithFeatures, PlayRecord } from './types.js';
|
|
6
|
+
/**
|
|
7
|
+
* Calculate recency penalty (0-1)
|
|
8
|
+
*
|
|
9
|
+
* 0 = no penalty, 1 = maximum penalty (completely exclude)
|
|
10
|
+
*
|
|
11
|
+
* @param track Track to check
|
|
12
|
+
* @param history Play history
|
|
13
|
+
* @param limits Repetition limits from policy
|
|
14
|
+
* @returns Penalty value 0-1
|
|
15
|
+
*/
|
|
16
|
+
export declare function checkRecencyPenalty(track: TrackWithFeatures, history: PlayRecord[], limits?: RepetitionLimits): number;
|
|
17
|
+
/**
|
|
18
|
+
* Get tracks played in the last N hours
|
|
19
|
+
*
|
|
20
|
+
* @param history Play history
|
|
21
|
+
* @param hoursAgo Hours to look back
|
|
22
|
+
* @returns Recent play records
|
|
23
|
+
*/
|
|
24
|
+
export declare function getRecentPlays(history: PlayRecord[], hoursAgo: number): PlayRecord[];
|
|
25
|
+
/**
|
|
26
|
+
* Get unique artists from history
|
|
27
|
+
*
|
|
28
|
+
* @param history Play history
|
|
29
|
+
* @param hoursAgo Hours to look back
|
|
30
|
+
* @returns Array of artist IDs
|
|
31
|
+
*/
|
|
32
|
+
export declare function getRecentArtists(history: PlayRecord[], hoursAgo: number): string[];
|
|
33
|
+
//# sourceMappingURL=history.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"history.d.ts","sourceRoot":"","sources":["../src/history.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,0BAA0B,CAAC;AACjE,OAAO,KAAK,EAAE,iBAAiB,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC;AAEhE;;;;;;;;;GASG;AACH,wBAAgB,mBAAmB,CACjC,KAAK,EAAE,iBAAiB,EACxB,OAAO,EAAE,UAAU,EAAE,EACrB,MAAM,CAAC,EAAE,gBAAgB,GACxB,MAAM,CA+CR;AAED;;;;;;GAMG;AACH,wBAAgB,cAAc,CAC5B,OAAO,EAAE,UAAU,EAAE,EACrB,QAAQ,EAAE,MAAM,GACf,UAAU,EAAE,CAGd;AAED;;;;;;GAMG;AACH,wBAAgB,gBAAgB,CAC9B,OAAO,EAAE,UAAU,EAAE,EACrB,QAAQ,EAAE,MAAM,GACf,MAAM,EAAE,CAWV"}
|
package/dist/history.js
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Play history tracking and recency penalty calculation
|
|
3
|
+
*/
|
|
4
|
+
/**
|
|
5
|
+
* Calculate recency penalty (0-1)
|
|
6
|
+
*
|
|
7
|
+
* 0 = no penalty, 1 = maximum penalty (completely exclude)
|
|
8
|
+
*
|
|
9
|
+
* @param track Track to check
|
|
10
|
+
* @param history Play history
|
|
11
|
+
* @param limits Repetition limits from policy
|
|
12
|
+
* @returns Penalty value 0-1
|
|
13
|
+
*/
|
|
14
|
+
export function checkRecencyPenalty(track, history, limits) {
|
|
15
|
+
if (!limits) {
|
|
16
|
+
return 0;
|
|
17
|
+
}
|
|
18
|
+
const now = Date.now();
|
|
19
|
+
let penalty = 0;
|
|
20
|
+
// Check track repetition
|
|
21
|
+
if (limits.repeatTrackWithinDays) {
|
|
22
|
+
const windowMs = limits.repeatTrackWithinDays * 24 * 60 * 60 * 1000;
|
|
23
|
+
const recentPlay = history.find(r => r.trackId === track.id && (now - r.playedAt) < windowMs);
|
|
24
|
+
if (recentPlay) {
|
|
25
|
+
// Full penalty if played within window
|
|
26
|
+
penalty = Math.max(penalty, 1.0);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
// Check artist repetition
|
|
30
|
+
if (limits.repeatArtistWithinHours && penalty < 1.0) {
|
|
31
|
+
const windowMs = limits.repeatArtistWithinHours * 60 * 60 * 1000;
|
|
32
|
+
// Find recent plays of same artist
|
|
33
|
+
const recentArtistPlays = history.filter(r => {
|
|
34
|
+
const timeSince = now - r.playedAt;
|
|
35
|
+
if (timeSince >= windowMs)
|
|
36
|
+
return false;
|
|
37
|
+
// Use structured artistIds when available, fall back to exact name match
|
|
38
|
+
return r.artistIds.some(aid => {
|
|
39
|
+
if (track.artistIds && track.artistIds.length > 0) {
|
|
40
|
+
return track.artistIds.includes(aid);
|
|
41
|
+
}
|
|
42
|
+
return aid === track.artist;
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
if (recentArtistPlays.length > 0) {
|
|
46
|
+
// Graduated penalty based on how many recent plays
|
|
47
|
+
const artistPenalty = Math.min(recentArtistPlays.length * 0.3, 0.8);
|
|
48
|
+
penalty = Math.max(penalty, artistPenalty);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return penalty;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Get tracks played in the last N hours
|
|
55
|
+
*
|
|
56
|
+
* @param history Play history
|
|
57
|
+
* @param hoursAgo Hours to look back
|
|
58
|
+
* @returns Recent play records
|
|
59
|
+
*/
|
|
60
|
+
export function getRecentPlays(history, hoursAgo) {
|
|
61
|
+
const cutoff = Date.now() - (hoursAgo * 60 * 60 * 1000);
|
|
62
|
+
return history.filter(r => r.playedAt >= cutoff);
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Get unique artists from history
|
|
66
|
+
*
|
|
67
|
+
* @param history Play history
|
|
68
|
+
* @param hoursAgo Hours to look back
|
|
69
|
+
* @returns Array of artist IDs
|
|
70
|
+
*/
|
|
71
|
+
export function getRecentArtists(history, hoursAgo) {
|
|
72
|
+
const recent = getRecentPlays(history, hoursAgo);
|
|
73
|
+
const artists = new Set();
|
|
74
|
+
for (const record of recent) {
|
|
75
|
+
for (const artistId of record.artistIds) {
|
|
76
|
+
artists.add(artistId);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return Array.from(artists);
|
|
80
|
+
}
|
|
81
|
+
//# sourceMappingURL=history.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"history.js","sourceRoot":"","sources":["../src/history.ts"],"names":[],"mappings":"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"}
|