@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/dist/index.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Harmon Core - Session engine, ranking, and adaptation
|
|
3
|
+
*/
|
|
4
|
+
export { createEngine } from './engine.js';
|
|
5
|
+
export { rankTracks } from './ranking.js';
|
|
6
|
+
export { fetchCandidates } from './sources.js';
|
|
7
|
+
export { calculateArcModulation } from './arc.js';
|
|
8
|
+
export { checkRecencyPenalty, getRecentPlays, getRecentArtists } from './history.js';
|
|
9
|
+
export type { SessionEngine, EngineConfig, } from './engine.js';
|
|
10
|
+
export type { AudioFeatures, MusicProvider, PlaybackController, TrackWithFeatures, PlayRecord, SessionState, EngineEvent, EventCallback, EngineLogger, RankedTrack, SessionStore, SourcesConfig, } from './types.js';
|
|
11
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH,OAAO,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAC3C,OAAO,EAAE,UAAU,EAAE,MAAM,cAAc,CAAC;AAC1C,OAAO,EAAE,eAAe,EAAE,MAAM,cAAc,CAAC;AAC/C,OAAO,EAAE,sBAAsB,EAAE,MAAM,UAAU,CAAC;AAClD,OAAO,EAAE,mBAAmB,EAAE,cAAc,EAAE,gBAAgB,EAAE,MAAM,cAAc,CAAC;AAErF,YAAY,EACV,aAAa,EACb,YAAY,GACb,MAAM,aAAa,CAAC;AAErB,YAAY,EACV,aAAa,EACb,aAAa,EACb,kBAAkB,EAClB,iBAAiB,EACjB,UAAU,EACV,YAAY,EACZ,WAAW,EACX,aAAa,EACb,YAAY,EACZ,WAAW,EACX,YAAY,EACZ,aAAa,GACd,MAAM,YAAY,CAAC"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Harmon Core - Session engine, ranking, and adaptation
|
|
3
|
+
*/
|
|
4
|
+
export { createEngine } from './engine.js';
|
|
5
|
+
export { rankTracks } from './ranking.js';
|
|
6
|
+
export { fetchCandidates } from './sources.js';
|
|
7
|
+
export { calculateArcModulation } from './arc.js';
|
|
8
|
+
export { checkRecencyPenalty, getRecentPlays, getRecentArtists } from './history.js';
|
|
9
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH,OAAO,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAC3C,OAAO,EAAE,UAAU,EAAE,MAAM,cAAc,CAAC;AAC1C,OAAO,EAAE,eAAe,EAAE,MAAM,cAAc,CAAC;AAC/C,OAAO,EAAE,sBAAsB,EAAE,MAAM,UAAU,CAAC;AAClD,OAAO,EAAE,mBAAmB,EAAE,cAAc,EAAE,gBAAgB,EAAE,MAAM,cAAc,CAAC"}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Two-phase track ranking algorithm
|
|
3
|
+
*/
|
|
4
|
+
import type { SessionPolicy } from '@sriinnu/harmon-protocol';
|
|
5
|
+
import type { TrackWithFeatures, PlayRecord, RankedTrack } from './types.js';
|
|
6
|
+
/**
|
|
7
|
+
* Two-phase ranking:
|
|
8
|
+
* 1. Hard constraint filtering (pass/fail)
|
|
9
|
+
* 2. Soft weight scoring (0-1 score)
|
|
10
|
+
*
|
|
11
|
+
* @param candidates Candidate tracks to rank
|
|
12
|
+
* @param policy Session policy
|
|
13
|
+
* @param history Play history
|
|
14
|
+
* @param elapsedMs Elapsed session time in milliseconds
|
|
15
|
+
* @returns Ranked tracks sorted by score (descending)
|
|
16
|
+
*/
|
|
17
|
+
export declare function rankTracks(candidates: TrackWithFeatures[], policy: SessionPolicy, history: PlayRecord[], elapsedMs: number): Promise<RankedTrack[]>;
|
|
18
|
+
//# sourceMappingURL=ranking.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ranking.d.ts","sourceRoot":"","sources":["../src/ranking.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,0BAA0B,CAAC;AAC9D,OAAO,KAAK,EAAE,iBAAiB,EAAE,UAAU,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AAI7E;;;;;;;;;;GAUG;AACH,wBAAsB,UAAU,CAC9B,UAAU,EAAE,iBAAiB,EAAE,EAC/B,MAAM,EAAE,aAAa,EACrB,OAAO,EAAE,UAAU,EAAE,EACrB,SAAS,EAAE,MAAM,GAChB,OAAO,CAAC,WAAW,EAAE,CAAC,CA0BxB"}
|
package/dist/ranking.js
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Two-phase track ranking algorithm
|
|
3
|
+
*/
|
|
4
|
+
import { calculateArcModulation } from './arc.js';
|
|
5
|
+
import { checkRecencyPenalty } from './history.js';
|
|
6
|
+
/**
|
|
7
|
+
* Two-phase ranking:
|
|
8
|
+
* 1. Hard constraint filtering (pass/fail)
|
|
9
|
+
* 2. Soft weight scoring (0-1 score)
|
|
10
|
+
*
|
|
11
|
+
* @param candidates Candidate tracks to rank
|
|
12
|
+
* @param policy Session policy
|
|
13
|
+
* @param history Play history
|
|
14
|
+
* @param elapsedMs Elapsed session time in milliseconds
|
|
15
|
+
* @returns Ranked tracks sorted by score (descending)
|
|
16
|
+
*/
|
|
17
|
+
export async function rankTracks(candidates, policy, history, elapsedMs) {
|
|
18
|
+
// Phase 1: Hard constraints
|
|
19
|
+
const filtered = candidates.filter(candidate => passesHardConstraints(candidate, policy));
|
|
20
|
+
if (filtered.length === 0) {
|
|
21
|
+
return [];
|
|
22
|
+
}
|
|
23
|
+
// Phase 2: Soft scoring
|
|
24
|
+
const scored = filtered.map(track => {
|
|
25
|
+
const score = calculateSoftScore(track, policy, history, elapsedMs);
|
|
26
|
+
const reason = buildScoreReason(track, score);
|
|
27
|
+
return {
|
|
28
|
+
track,
|
|
29
|
+
score,
|
|
30
|
+
reason,
|
|
31
|
+
};
|
|
32
|
+
});
|
|
33
|
+
// Sort descending by score
|
|
34
|
+
scored.sort((a, b) => b.score - a.score);
|
|
35
|
+
return scored;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Phase 1: Hard constraint filtering
|
|
39
|
+
*/
|
|
40
|
+
function passesHardConstraints(track, policy) {
|
|
41
|
+
const hard = policy.hard || {};
|
|
42
|
+
const features = track.features;
|
|
43
|
+
// No vocals check
|
|
44
|
+
if (hard.noVocals && features.instrumentalness < 0.5) {
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
// Explicit content check
|
|
48
|
+
if (hard.explicit === 'avoid' && track.explicit === true) {
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
if (hard.explicit === 'require' && track.explicit !== true) {
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
// Tempo range
|
|
55
|
+
if (hard.tempo) {
|
|
56
|
+
if (hard.tempo.min !== undefined && features.tempo < hard.tempo.min) {
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
if (hard.tempo.max !== undefined && features.tempo > hard.tempo.max) {
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
// Energy range
|
|
64
|
+
if (hard.energy) {
|
|
65
|
+
if (hard.energy.min !== undefined && features.energy < hard.energy.min) {
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
if (hard.energy.max !== undefined && features.energy > hard.energy.max) {
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
// Instrumentalness minimum
|
|
73
|
+
if (hard.instrumentalnessMin !== undefined) {
|
|
74
|
+
if (features.instrumentalness < hard.instrumentalnessMin) {
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return true;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Phase 2: Soft scoring (0-1)
|
|
82
|
+
*/
|
|
83
|
+
function calculateSoftScore(track, policy, history, elapsedMs) {
|
|
84
|
+
const soft = policy.soft || {};
|
|
85
|
+
const weights = soft.weights || {};
|
|
86
|
+
const features = track.features;
|
|
87
|
+
let score = 0;
|
|
88
|
+
let totalWeight = 0;
|
|
89
|
+
// Energy weight (with arc modulation)
|
|
90
|
+
if (typeof weights.energy === 'number') {
|
|
91
|
+
const arcMod = calculateArcModulation(elapsedMs, soft.arc, policy.durationMs);
|
|
92
|
+
const targetEnergy = 0.5 + arcMod; // Base 0.5, modified by arc
|
|
93
|
+
const energyScore = 1 - Math.abs(features.energy - targetEnergy);
|
|
94
|
+
score += weights.energy * energyScore;
|
|
95
|
+
totalWeight += Math.abs(weights.energy);
|
|
96
|
+
}
|
|
97
|
+
// Instrumentalness weight
|
|
98
|
+
if (typeof weights.instrumentalness === 'number') {
|
|
99
|
+
score += weights.instrumentalness * features.instrumentalness;
|
|
100
|
+
totalWeight += Math.abs(weights.instrumentalness);
|
|
101
|
+
}
|
|
102
|
+
// Speechiness weight (usually negative)
|
|
103
|
+
if (typeof weights.speechiness === 'number') {
|
|
104
|
+
const speechScore = weights.speechiness > 0
|
|
105
|
+
? features.speechiness
|
|
106
|
+
: (1 - features.speechiness);
|
|
107
|
+
score += Math.abs(weights.speechiness) * speechScore;
|
|
108
|
+
totalWeight += Math.abs(weights.speechiness);
|
|
109
|
+
}
|
|
110
|
+
// Valence weight
|
|
111
|
+
if (typeof weights.valence === 'number') {
|
|
112
|
+
score += weights.valence * features.valence;
|
|
113
|
+
totalWeight += Math.abs(weights.valence);
|
|
114
|
+
}
|
|
115
|
+
// Acousticness weight
|
|
116
|
+
if (typeof weights.acousticness === 'number') {
|
|
117
|
+
score += weights.acousticness * features.acousticness;
|
|
118
|
+
totalWeight += Math.abs(weights.acousticness);
|
|
119
|
+
}
|
|
120
|
+
// Tempo weight (normalized to 0-1, assuming 60-180 BPM range)
|
|
121
|
+
if (typeof weights.tempo === 'number') {
|
|
122
|
+
const normalizedTempo = clamp((features.tempo - 60) / 120, 0, 1);
|
|
123
|
+
score += weights.tempo * normalizedTempo;
|
|
124
|
+
totalWeight += Math.abs(weights.tempo);
|
|
125
|
+
}
|
|
126
|
+
// Normalize by total weight
|
|
127
|
+
if (totalWeight > 0) {
|
|
128
|
+
score = score / totalWeight;
|
|
129
|
+
}
|
|
130
|
+
// Clamp to [0,1] BEFORE applying penalty (prevents negative * penalty inversion)
|
|
131
|
+
score = clamp(score, 0, 1);
|
|
132
|
+
// Apply recency penalty
|
|
133
|
+
const recencyPenalty = checkRecencyPenalty(track, history, policy.limits);
|
|
134
|
+
score = score * (1 - recencyPenalty);
|
|
135
|
+
return clamp(score, 0, 1);
|
|
136
|
+
}
|
|
137
|
+
function buildScoreReason(track, score) {
|
|
138
|
+
return `${track.name} scored ${score.toFixed(3)}`;
|
|
139
|
+
}
|
|
140
|
+
function clamp(value, min, max) {
|
|
141
|
+
return Math.min(max, Math.max(min, value));
|
|
142
|
+
}
|
|
143
|
+
//# sourceMappingURL=ranking.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ranking.js","sourceRoot":"","sources":["../src/ranking.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Candidate fetching from music providers (provider-agnostic)
|
|
3
|
+
*/
|
|
4
|
+
import type { MusicProvider, SourcesConfig, TrackWithFeatures, EngineLogger } from './types.js';
|
|
5
|
+
/**
|
|
6
|
+
* Fetch candidate tracks from configured sources
|
|
7
|
+
*
|
|
8
|
+
* @param provider Music provider (Spotify, Apple Music, YouTube Music, etc.)
|
|
9
|
+
* @param sources Sources configuration
|
|
10
|
+
* @param targetCount Target number of candidates
|
|
11
|
+
* @returns Array of tracks with audio features
|
|
12
|
+
*/
|
|
13
|
+
export declare function fetchCandidates(provider: MusicProvider, sources: SourcesConfig, targetCount: number, logger?: EngineLogger): Promise<TrackWithFeatures[]>;
|
|
14
|
+
//# sourceMappingURL=sources.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sources.d.ts","sourceRoot":"","sources":["../src/sources.ts"],"names":[],"mappings":"AAAA;;GAEG;AAGH,OAAO,KAAK,EAAE,aAAa,EAAE,aAAa,EAAE,iBAAiB,EAAiB,YAAY,EAAE,MAAM,YAAY,CAAC;AAS/G;;;;;;;GAOG;AACH,wBAAsB,eAAe,CACnC,QAAQ,EAAE,aAAa,EACvB,OAAO,EAAE,aAAa,EACtB,WAAW,EAAE,MAAM,EACnB,MAAM,CAAC,EAAE,YAAY,GACpB,OAAO,CAAC,iBAAiB,EAAE,CAAC,CA6F9B"}
|
package/dist/sources.js
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Candidate fetching from music providers (provider-agnostic)
|
|
3
|
+
*/
|
|
4
|
+
/** Default features for tracks from providers without audio analysis (Apple Music, YouTube Music) */
|
|
5
|
+
const DEFAULT_FEATURES = {
|
|
6
|
+
energy: 0.5, instrumentalness: 0.1, speechiness: 0.1, valence: 0.5,
|
|
7
|
+
acousticness: 0.3, tempo: 120, danceability: 0.5, liveness: 0.2,
|
|
8
|
+
loudness: -8, key: 5, mode: 1, timeSignature: 4,
|
|
9
|
+
};
|
|
10
|
+
/**
|
|
11
|
+
* Fetch candidate tracks from configured sources
|
|
12
|
+
*
|
|
13
|
+
* @param provider Music provider (Spotify, Apple Music, YouTube Music, etc.)
|
|
14
|
+
* @param sources Sources configuration
|
|
15
|
+
* @param targetCount Target number of candidates
|
|
16
|
+
* @returns Array of tracks with audio features
|
|
17
|
+
*/
|
|
18
|
+
export async function fetchCandidates(provider, sources, targetCount, logger) {
|
|
19
|
+
const candidates = [];
|
|
20
|
+
const perSource = Math.ceil(targetCount / countActiveSources(sources));
|
|
21
|
+
// Liked/library tracks
|
|
22
|
+
if (sources.likedTracks) {
|
|
23
|
+
const tracks = await fetchSourceTracks('liked tracks', () => provider.getLibraryTracks({ limit: perSource }), logger);
|
|
24
|
+
const withFeatures = await enrichWithFeatures(provider, tracks, logger);
|
|
25
|
+
candidates.push(...withFeatures);
|
|
26
|
+
}
|
|
27
|
+
// Top tracks
|
|
28
|
+
if (sources.topTracks) {
|
|
29
|
+
const tracks = await fetchSourceTracks('top tracks', () => provider.getTopTracks({ limit: perSource }), logger);
|
|
30
|
+
const withFeatures = await enrichWithFeatures(provider, tracks, logger);
|
|
31
|
+
candidates.push(...withFeatures);
|
|
32
|
+
}
|
|
33
|
+
// Recent plays
|
|
34
|
+
if (sources.recentPlays) {
|
|
35
|
+
const tracks = await fetchSourceTracks('recent plays', () => provider.getRecentlyPlayed({ limit: perSource }), logger);
|
|
36
|
+
const withFeatures = await enrichWithFeatures(provider, tracks, logger);
|
|
37
|
+
candidates.push(...withFeatures);
|
|
38
|
+
}
|
|
39
|
+
// Search-seeded sources
|
|
40
|
+
if (sources.searchQueries && sources.searchQueries.length > 0) {
|
|
41
|
+
for (const query of sources.searchQueries.slice(0, 5)) {
|
|
42
|
+
const tracks = await fetchSourceTracks(`search ${query}`, () => provider.search(query, perSource), logger);
|
|
43
|
+
const withFeatures = await enrichWithFeatures(provider, tracks, logger);
|
|
44
|
+
candidates.push(...withFeatures);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
// Seed playlists
|
|
48
|
+
if (sources.seedPlaylists && sources.seedPlaylists.length > 0) {
|
|
49
|
+
const seedPlaylists = sources.seedPlaylists;
|
|
50
|
+
for (const playlistId of sources.seedPlaylists.slice(0, 3)) {
|
|
51
|
+
const tracks = await fetchSourceTracks(`playlist ${playlistId}`, () => provider.getPlaylistTracks(extractId(playlistId), { limit: Math.ceil(perSource / seedPlaylists.length) }), logger);
|
|
52
|
+
const withFeatures = await enrichWithFeatures(provider, tracks, logger);
|
|
53
|
+
candidates.push(...withFeatures);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
// Discovery via recommendations
|
|
57
|
+
if (sources.discovery?.enabled) {
|
|
58
|
+
const discoveryCount = Math.ceil(targetCount * (sources.discovery.ratio || 0.15));
|
|
59
|
+
const seedTracks = candidates.slice(0, 5).map((track) => track.id);
|
|
60
|
+
if (seedTracks.length > 0) {
|
|
61
|
+
const recommendations = await fetchSourceTracks('recommendations', () => provider.getRecommendations({
|
|
62
|
+
seedTrackIds: seedTracks,
|
|
63
|
+
limit: discoveryCount,
|
|
64
|
+
}), logger);
|
|
65
|
+
const withFeatures = await enrichWithFeatures(provider, recommendations, logger);
|
|
66
|
+
candidates.push(...withFeatures);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
// Deduplicate by track ID
|
|
70
|
+
return deduplicateTracks(candidates);
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* I isolate one provider source failure so one unsupported capability does not
|
|
74
|
+
* wipe out the rest of the candidate collection pass.
|
|
75
|
+
*/
|
|
76
|
+
async function fetchSourceTracks(sourceName, loader, logger) {
|
|
77
|
+
try {
|
|
78
|
+
return await loader();
|
|
79
|
+
}
|
|
80
|
+
catch (error) {
|
|
81
|
+
if (!isExplicitlyUnsupportedSourceError(error)) {
|
|
82
|
+
throw error;
|
|
83
|
+
}
|
|
84
|
+
logger?.warn({ source: sourceName, error: error instanceof Error ? error.message : String(error) }, `Skipping source ${sourceName}`);
|
|
85
|
+
return [];
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
function isExplicitlyUnsupportedSourceError(error) {
|
|
89
|
+
if (!(error instanceof Error)) {
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
return /not implemented|not available/i.test(error.message);
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Enrich tracks with audio features.
|
|
96
|
+
* Uses positional correspondence — provider.getTrackFeatures returns
|
|
97
|
+
* (AudioFeatures | null)[] with nulls preserved at correct indices.
|
|
98
|
+
*/
|
|
99
|
+
async function enrichWithFeatures(provider, tracks, logger) {
|
|
100
|
+
if (tracks.length === 0) {
|
|
101
|
+
return [];
|
|
102
|
+
}
|
|
103
|
+
const trackIds = tracks.map(t => t.id);
|
|
104
|
+
let featureResults = await provider.getTrackFeatures(trackIds);
|
|
105
|
+
// If the provider breaks positional correspondence, I prefer
|
|
106
|
+
// a safe full fallback over silently attaching the wrong features
|
|
107
|
+
// to the wrong tracks.
|
|
108
|
+
if (featureResults.length !== tracks.length) {
|
|
109
|
+
logger?.warn({ expected: tracks.length, received: featureResults.length }, 'Feature result length mismatch — falling back to defaults');
|
|
110
|
+
featureResults = Array.from({ length: tracks.length }, () => null);
|
|
111
|
+
}
|
|
112
|
+
// Match features to tracks by position (nulls preserved).
|
|
113
|
+
// Tracks without features get DEFAULT_FEATURES so non-Spotify providers still work.
|
|
114
|
+
const enriched = [];
|
|
115
|
+
for (let i = 0; i < tracks.length; i++) {
|
|
116
|
+
const feature = featureResults[i];
|
|
117
|
+
enriched.push({
|
|
118
|
+
...tracks[i],
|
|
119
|
+
features: feature || DEFAULT_FEATURES,
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
return enriched;
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Remove duplicate tracks by ID
|
|
126
|
+
*/
|
|
127
|
+
function deduplicateTracks(tracks) {
|
|
128
|
+
const seen = new Set();
|
|
129
|
+
const unique = [];
|
|
130
|
+
for (const track of tracks) {
|
|
131
|
+
if (!seen.has(track.id)) {
|
|
132
|
+
seen.add(track.id);
|
|
133
|
+
unique.push(track);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return unique;
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Count active sources for distribution
|
|
140
|
+
*/
|
|
141
|
+
function countActiveSources(sources) {
|
|
142
|
+
let count = 0;
|
|
143
|
+
if (sources.likedTracks)
|
|
144
|
+
count++;
|
|
145
|
+
if (sources.topTracks)
|
|
146
|
+
count++;
|
|
147
|
+
if (sources.recentPlays)
|
|
148
|
+
count++;
|
|
149
|
+
if (sources.searchQueries && sources.searchQueries.length > 0)
|
|
150
|
+
count++;
|
|
151
|
+
if (sources.seedPlaylists && sources.seedPlaylists.length > 0)
|
|
152
|
+
count++;
|
|
153
|
+
if (sources.discovery?.enabled)
|
|
154
|
+
count++;
|
|
155
|
+
return Math.max(count, 1);
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Extract provider ID from URI or URL
|
|
159
|
+
*/
|
|
160
|
+
function extractId(uri) {
|
|
161
|
+
// Spotify URIs
|
|
162
|
+
if (uri.startsWith('spotify:')) {
|
|
163
|
+
return uri.split(':').pop() || uri;
|
|
164
|
+
}
|
|
165
|
+
if (uri.startsWith('youtube:playlist:')) {
|
|
166
|
+
return uri.split(':').pop() || uri;
|
|
167
|
+
}
|
|
168
|
+
// Spotify/YouTube URLs
|
|
169
|
+
if (uri.includes('spotify.com/') || uri.includes('youtube.com/') || uri.includes('music.apple.com/')) {
|
|
170
|
+
try {
|
|
171
|
+
const url = new URL(uri);
|
|
172
|
+
const playlistId = url.searchParams.get('list');
|
|
173
|
+
if (playlistId) {
|
|
174
|
+
return playlistId;
|
|
175
|
+
}
|
|
176
|
+
const parts = url.pathname.split('/').filter(Boolean);
|
|
177
|
+
return parts[parts.length - 1] || uri;
|
|
178
|
+
}
|
|
179
|
+
catch {
|
|
180
|
+
const parts = uri.split('/');
|
|
181
|
+
return parts[parts.length - 1].split('?')[0];
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return uri;
|
|
185
|
+
}
|
|
186
|
+
//# sourceMappingURL=sources.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sources.js","sourceRoot":"","sources":["../src/sources.ts"],"names":[],"mappings":"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"}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Internal types for harmon-core
|
|
3
|
+
*/
|
|
4
|
+
import type { TrackInfo, SessionPolicy } from '@sriinnu/harmon-protocol';
|
|
5
|
+
/**
|
|
6
|
+
* Audio features for track ranking.
|
|
7
|
+
* Canonical definition — providers map their features to this shape.
|
|
8
|
+
* Providers that don't support features (YouTube Music) return null.
|
|
9
|
+
*/
|
|
10
|
+
export interface AudioFeatures {
|
|
11
|
+
energy: number;
|
|
12
|
+
instrumentalness: number;
|
|
13
|
+
speechiness: number;
|
|
14
|
+
valence: number;
|
|
15
|
+
acousticness: number;
|
|
16
|
+
tempo: number;
|
|
17
|
+
danceability: number;
|
|
18
|
+
liveness: number;
|
|
19
|
+
loudness: number;
|
|
20
|
+
key: number;
|
|
21
|
+
mode: number;
|
|
22
|
+
timeSignature: number;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Extended TrackInfo with audio features attached
|
|
26
|
+
*/
|
|
27
|
+
export interface TrackWithFeatures extends TrackInfo {
|
|
28
|
+
features: AudioFeatures;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Provider-agnostic interface for fetching music data.
|
|
32
|
+
* Each music service (Spotify, Apple Music, YouTube Music) implements this.
|
|
33
|
+
*/
|
|
34
|
+
export interface MusicProvider {
|
|
35
|
+
readonly name: 'spotify' | 'apple' | 'youtube' | 'local';
|
|
36
|
+
isConnected(): boolean;
|
|
37
|
+
/** Search for tracks */
|
|
38
|
+
search(query: string, limit?: number): Promise<TrackInfo[]>;
|
|
39
|
+
/** Get user's library/saved tracks */
|
|
40
|
+
getLibraryTracks(options?: {
|
|
41
|
+
limit?: number;
|
|
42
|
+
offset?: number;
|
|
43
|
+
}): Promise<TrackInfo[]>;
|
|
44
|
+
/** Get user's top/most-played tracks */
|
|
45
|
+
getTopTracks(options?: {
|
|
46
|
+
limit?: number;
|
|
47
|
+
timeRange?: string;
|
|
48
|
+
}): Promise<TrackInfo[]>;
|
|
49
|
+
/** Get recently played tracks */
|
|
50
|
+
getRecentlyPlayed(options?: {
|
|
51
|
+
limit?: number;
|
|
52
|
+
}): Promise<TrackInfo[]>;
|
|
53
|
+
/** Get tracks from a specific playlist */
|
|
54
|
+
getPlaylistTracks(playlistId: string, options?: {
|
|
55
|
+
limit?: number;
|
|
56
|
+
}): Promise<TrackInfo[]>;
|
|
57
|
+
/** Get recommendations based on seed tracks */
|
|
58
|
+
getRecommendations(options: {
|
|
59
|
+
seedTrackIds?: string[];
|
|
60
|
+
limit?: number;
|
|
61
|
+
}): Promise<TrackInfo[]>;
|
|
62
|
+
/**
|
|
63
|
+
* Get audio features for tracks.
|
|
64
|
+
* Returns array with same length as trackIds. Null for tracks without features.
|
|
65
|
+
*/
|
|
66
|
+
getTrackFeatures(trackIds: string[]): Promise<(AudioFeatures | null)[]>;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Provider-agnostic playback controller.
|
|
70
|
+
* Optional methods (seek, volume, etc.) may not be supported by all providers.
|
|
71
|
+
*/
|
|
72
|
+
export interface PlaybackController {
|
|
73
|
+
readonly name: 'spotify' | 'apple' | 'youtube' | 'local';
|
|
74
|
+
play(options?: {
|
|
75
|
+
uri?: string;
|
|
76
|
+
trackId?: string;
|
|
77
|
+
}): Promise<void>;
|
|
78
|
+
pause(): Promise<void>;
|
|
79
|
+
next(): Promise<void>;
|
|
80
|
+
previous(): Promise<void>;
|
|
81
|
+
seek?(positionMs: number): Promise<void>;
|
|
82
|
+
setVolume?(volumePercent: number): Promise<void>;
|
|
83
|
+
setShuffle?(state: boolean): Promise<void>;
|
|
84
|
+
setRepeat?(state: 'off' | 'track' | 'context'): Promise<void>;
|
|
85
|
+
getNowPlaying(): Promise<TrackInfo | null>;
|
|
86
|
+
addToQueue(trackUri: string, track?: TrackInfo): Promise<void>;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Track play record in history
|
|
90
|
+
*/
|
|
91
|
+
export interface PlayRecord {
|
|
92
|
+
trackId: string;
|
|
93
|
+
artistIds: string[];
|
|
94
|
+
playedAt: number;
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Storage contract required by the session engine.
|
|
98
|
+
* I keep this narrow so harmon-core stays decoupled from any concrete store.
|
|
99
|
+
*/
|
|
100
|
+
export interface SessionStore {
|
|
101
|
+
createSession(policy: string): Promise<string>;
|
|
102
|
+
endSession(id: string): Promise<void>;
|
|
103
|
+
logEvent(type: string, payload: Record<string, unknown>, sessionId?: string): Promise<string>;
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Session state managed by engine
|
|
107
|
+
*/
|
|
108
|
+
export interface SessionState {
|
|
109
|
+
id: string;
|
|
110
|
+
policy: SessionPolicy;
|
|
111
|
+
startedAt: number;
|
|
112
|
+
status: 'idle' | 'running' | 'paused';
|
|
113
|
+
history: PlayRecord[];
|
|
114
|
+
currentTrack: TrackInfo | null;
|
|
115
|
+
queuedTracks: TrackInfo[];
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Event emitted by engine
|
|
119
|
+
*/
|
|
120
|
+
export interface EngineEvent {
|
|
121
|
+
type: 'session.started' | 'session.stopped' | 'session.nudged' | 'track.started' | 'queue.refilled' | 'error';
|
|
122
|
+
payload: Record<string, unknown>;
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Event callback type
|
|
126
|
+
*/
|
|
127
|
+
export type EventCallback = (event: EngineEvent) => void;
|
|
128
|
+
/**
|
|
129
|
+
* Minimal logger contract for engine internals.
|
|
130
|
+
* Matches the subset of pino that harmon-core actually uses,
|
|
131
|
+
* so the core package stays decoupled from any concrete logger.
|
|
132
|
+
*/
|
|
133
|
+
export interface EngineLogger {
|
|
134
|
+
warn(obj: Record<string, unknown>, msg: string): void;
|
|
135
|
+
error(obj: Record<string, unknown>, msg: string): void;
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Ranking result with score
|
|
139
|
+
*/
|
|
140
|
+
export interface RankedTrack {
|
|
141
|
+
track: TrackWithFeatures;
|
|
142
|
+
score: number;
|
|
143
|
+
reason: string;
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Sources configuration for fetching candidates
|
|
147
|
+
*/
|
|
148
|
+
export interface SourcesConfig {
|
|
149
|
+
likedTracks?: boolean;
|
|
150
|
+
topTracks?: boolean;
|
|
151
|
+
recentPlays?: boolean;
|
|
152
|
+
searchQueries?: string[];
|
|
153
|
+
seedPlaylists?: string[];
|
|
154
|
+
seedArtists?: string[];
|
|
155
|
+
discovery?: {
|
|
156
|
+
enabled?: boolean;
|
|
157
|
+
ratio?: number;
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
//# sourceMappingURL=types.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"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"}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA;;GAEG"}
|
package/logo.svg
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" fill="none">
|
|
2
|
+
<!-- I am the core mark: a small engine-heart with adjustment arms. -->
|
|
3
|
+
<rect width="64" height="64" rx="16" fill="#F3E7D4"/>
|
|
4
|
+
<g stroke="#171513" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
|
|
5
|
+
<path d="M19 20L23 16"/>
|
|
6
|
+
<path d="M41 20L45 16"/>
|
|
7
|
+
<path d="M15 33H19"/>
|
|
8
|
+
<path d="M45 33H49"/>
|
|
9
|
+
<path d="M22 46L19 50"/>
|
|
10
|
+
<path d="M42 46L45 50"/>
|
|
11
|
+
<path d="M32 21C36 16 45 20 45 27C45 38 32 44 32 44C32 44 19 38 19 27C19 20 28 16 32 21Z"/>
|
|
12
|
+
<circle cx="32" cy="31" r="5"/>
|
|
13
|
+
</g>
|
|
14
|
+
</svg>
|