commitsmusic 1.0.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 +21 -0
- package/README.md +64 -0
- package/bin/commitsmusic.js +103 -0
- package/package.json +24 -0
- package/src/animation.js +205 -0
- package/src/engine.js +238 -0
- package/src/tui.js +128 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Meinianda
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# π΅ CommitsMusic
|
|
2
|
+
|
|
3
|
+
Turn any git repository's history into **music**. One command, zero dependencies.
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
gitmuse ~/my-project
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
Each commit becomes a musical note. The melody is shaped by your commit patterns β late-night commits sound different from morning commits, big refactors hit harder than small fixes.
|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
npm install -g gitmuse-cli
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Requires **Node.js >= 16**. That's it β no Python, no sound fonts, no external tools.
|
|
18
|
+
|
|
19
|
+
## Usage
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
gitmuse # current directory
|
|
23
|
+
gitmuse ~/my-project # specific repo
|
|
24
|
+
gitmuse --no-anim ~/repo # audio only, skip animation
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Set how many commits to use:
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
GITMUSE_MAX=100 gitmuse # default: 200
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## How it works
|
|
34
|
+
|
|
35
|
+
```
|
|
36
|
+
git log β key detection β commitβpitch mapping β Markov smoothing
|
|
37
|
+
β rhythmic phrasing (5 patterns) β piano synthesis
|
|
38
|
+
β accompaniment (strings, bass, harp, drums) β WAV β playback
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
**Commits control the pitch** β your commit hours directly map to notes
|
|
42
|
+
**Algorithmic rhythm** β phrases are grouped by real time gaps, with swing and syncopation
|
|
43
|
+
**Piano synthesis** β additive harmonics with ADSR envelope and hammer noise
|
|
44
|
+
**Every note validated** β snapped to the detected key, no wrong notes
|
|
45
|
+
|
|
46
|
+
## What you'll hear
|
|
47
|
+
|
|
48
|
+
| Instrument | Role |
|
|
49
|
+
|-----------|------|
|
|
50
|
+
| Piano | Lead melody (from commits) |
|
|
51
|
+
| Strings | Harmony + counter-melody |
|
|
52
|
+
| Bass | Walking root-fifth pattern |
|
|
53
|
+
| Harp | Arpeggiated chords |
|
|
54
|
+
| Drums | Kick, snare, hi-hat, clap |
|
|
55
|
+
|
|
56
|
+
## Try it by cloning this repo
|
|
57
|
+
|
|
58
|
+
```
|
|
59
|
+
git clone https://github.com/Meinianda-L/GitMuse
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## License
|
|
63
|
+
|
|
64
|
+
MIT
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/** commitsmusic β git commit history β music */
|
|
3
|
+
|
|
4
|
+
const fs=require('fs'),path=require('path');
|
|
5
|
+
const{generate,writeWav}=require('../src/engine');
|
|
6
|
+
const{playWithAnimation}=require('../src/animation');
|
|
7
|
+
const{spawn}=require('child_process');
|
|
8
|
+
|
|
9
|
+
// Terminal styling
|
|
10
|
+
const CSI='\x1b[';
|
|
11
|
+
const fg=c=>CSI+'38;5;'+c+'m';
|
|
12
|
+
const dim=CSI+'2m';
|
|
13
|
+
const bold=CSI+'1m';
|
|
14
|
+
const reset=CSI+'0m';
|
|
15
|
+
|
|
16
|
+
// βββ Parse CLI args ββββββββββββββββββββββββββββββββββββββββββ
|
|
17
|
+
const args=process.argv.slice(2);
|
|
18
|
+
|
|
19
|
+
let mode='direct';
|
|
20
|
+
let repo='.';
|
|
21
|
+
let nCommits=30;
|
|
22
|
+
let animate=true;
|
|
23
|
+
|
|
24
|
+
for(let i=0;i<args.length;i++){
|
|
25
|
+
const a=args[i];
|
|
26
|
+
if(a==='tui'){mode='tui';continue}
|
|
27
|
+
if(a==='-noan'||a==='--no-animation'){animate=false;continue}
|
|
28
|
+
if(a.startsWith('-')&&!isNaN(a.slice(1))){nCommits=parseInt(a.slice(1));continue}
|
|
29
|
+
if(!a.startsWith('-')){repo=a;continue}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Resolve repo path
|
|
33
|
+
repo=path.resolve(repo);
|
|
34
|
+
|
|
35
|
+
if(mode==='tui'){
|
|
36
|
+
runTUI();
|
|
37
|
+
}else{
|
|
38
|
+
runDirect(repo,nCommits,animate);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// βββ Direct Mode ββββββββββββββββββββββββββββββββββββββββββββ
|
|
42
|
+
async function runDirect(repo,n,anim){
|
|
43
|
+
try{
|
|
44
|
+
console.log(`\n ${bold}commitsmusic${reset} β ${dim}${repo}${reset}\n`);
|
|
45
|
+
|
|
46
|
+
const res=generate(repo,n);
|
|
47
|
+
const{key,totalTime,mel,acc,chordSeq,samples,commitNotes,cs}=res;
|
|
48
|
+
|
|
49
|
+
console.log(` Key: ${fg(220)}${key.name} ${key.mode}${reset} | ${cs.length}c β ${mel.length}n β ${Math.round(totalTime)}s`);
|
|
50
|
+
console.log(` Chords: ${chordSeq.join(' β ')}`);
|
|
51
|
+
|
|
52
|
+
// Write WAV
|
|
53
|
+
const wavPath=path.join(repo,'.commitsmusic.wav');
|
|
54
|
+
writeWav(samples,wavPath);
|
|
55
|
+
console.log(` wav: ${dim}${wavPath}${reset}\n`);
|
|
56
|
+
|
|
57
|
+
if(anim){
|
|
58
|
+
console.log(' Playing with animation... (q to quit)\n');
|
|
59
|
+
await new Promise(r=>setTimeout(r,500));
|
|
60
|
+
await playWithAnimation(samples,mel,commitNotes,totalTime,wavPath,key,cs.length);
|
|
61
|
+
}else{
|
|
62
|
+
console.log(' Playing...');
|
|
63
|
+
spawn('afplay',[wavPath],{stdio:'ignore'});
|
|
64
|
+
await new Promise(r=>setTimeout(r,totalTime*1000+500));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Ask to save
|
|
68
|
+
await askSave(wavPath);
|
|
69
|
+
}catch(e){
|
|
70
|
+
console.error(` Error: ${e.message}`);
|
|
71
|
+
process.exit(1);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// βββ Save Prompt ββββββββββββββββββββββββββββββββββββββββββββ
|
|
76
|
+
function askSave(wavPath){
|
|
77
|
+
return new Promise((resolve)=>{
|
|
78
|
+
const rl=require('readline').createInterface({input:process.stdin,output:process.stdout});
|
|
79
|
+
rl.question(`\n Save audio? [y/N/path]: `,(ans)=>{
|
|
80
|
+
rl.close();
|
|
81
|
+
if(!ans||ans.toLowerCase()==='n'){
|
|
82
|
+
fs.unlinkSync(wavPath);
|
|
83
|
+
console.log(' Deleted.\n');
|
|
84
|
+
}else if(ans.toLowerCase()==='y'){
|
|
85
|
+
console.log(` Saved: ${wavPath}\n`);
|
|
86
|
+
}else{
|
|
87
|
+
const dest=path.resolve(ans);
|
|
88
|
+
fs.copyFileSync(wavPath,dest);
|
|
89
|
+
fs.unlinkSync(wavPath);
|
|
90
|
+
console.log(` Saved: ${dest}\n`);
|
|
91
|
+
}
|
|
92
|
+
resolve();
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// βββ TUI Mode ββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
98
|
+
function runTUI(){
|
|
99
|
+
const{spawnSync}=require('child_process');
|
|
100
|
+
const tuiScript=path.join(__dirname,'..','src','tui.js');
|
|
101
|
+
const{status}=spawnSync(process.execPath,[tuiScript,...process.argv.slice(3)],{stdio:'inherit'});
|
|
102
|
+
process.exit(status||0);
|
|
103
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "commitsmusic",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Turn git commit history into music with a beautiful TUI",
|
|
5
|
+
"main": "src/engine.js",
|
|
6
|
+
"bin": {
|
|
7
|
+
"commitsmusic": "./bin/commitsmusic.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"bin/",
|
|
11
|
+
"src/"
|
|
12
|
+
],
|
|
13
|
+
"keywords": [
|
|
14
|
+
"git",
|
|
15
|
+
"music",
|
|
16
|
+
"tui",
|
|
17
|
+
"cli",
|
|
18
|
+
"visualization"
|
|
19
|
+
],
|
|
20
|
+
"license": "MIT",
|
|
21
|
+
"dependencies": {
|
|
22
|
+
"blessed": "^0.1.81"
|
|
23
|
+
}
|
|
24
|
+
}
|
package/src/animation.js
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
/** commitsmusic β unbound timeline β smooth transition β piano roll */
|
|
2
|
+
|
|
3
|
+
const { spawn } = require('child_process');
|
|
4
|
+
|
|
5
|
+
const R='\x1b[0m',FG=c=>`\x1b[38;5;${c}m`,BG=c=>`\x1b[48;5;${c}m`;
|
|
6
|
+
const B='\x1b[1m',D='\x1b[2m';
|
|
7
|
+
const AS='\x1b[?1049h',AE='\x1b[?1049l',HI='\x1b[?25l',SH='\x1b[?25h';
|
|
8
|
+
const HOME='\x1b[H',SB='\x1b[?2026h',SE='\x1b[?2026l';
|
|
9
|
+
|
|
10
|
+
function noteColor(midi){
|
|
11
|
+
const t=(midi-38)/46;
|
|
12
|
+
const r=Math.floor(55+t*150),g=Math.floor(60+t*130),b=Math.floor(130-t*60);
|
|
13
|
+
return `\x1b[38;2;${Math.min(255,r)};${Math.min(255,g)};${Math.min(255,b)}m`;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function playWithAnimation(samples,melody,commitNotes,totalTime,wavPath,key,nCommits){
|
|
17
|
+
return new Promise(async resolve=>{
|
|
18
|
+
|
|
19
|
+
const allCommits=[],seen=new Set();
|
|
20
|
+
for(const n of melody.filter(x=>!x.tp)){
|
|
21
|
+
const id=n.msg+n.t;
|
|
22
|
+
if(!seen.has(id)){seen.add(id);allCommits.push(n)}
|
|
23
|
+
}
|
|
24
|
+
allCommits.sort((a,b)=>a.t-b.t);
|
|
25
|
+
const notes=allCommits.map(n=>({t:n.t,dur:n.dur,midi:n.midi,msg:n.msg||'',au:n.au||'?',files:n.files||[]}));
|
|
26
|
+
|
|
27
|
+
let player=null,quit=false;
|
|
28
|
+
process.stdout.write(AS+HI);
|
|
29
|
+
const wr=process.stdin.isRaw;
|
|
30
|
+
if(process.stdin.isTTY) process.stdin.setRawMode(true);
|
|
31
|
+
process.stdin.on('data',d=>{if(d[0]===113||d[0]===3)quit=true});
|
|
32
|
+
|
|
33
|
+
const W=()=>process.stdout.columns||80,H=()=>process.stdout.rows||24;
|
|
34
|
+
|
|
35
|
+
// βββ Timeline β scrolls freely, no cap βββ
|
|
36
|
+
const introTime=Math.min(5,totalTime*.35);
|
|
37
|
+
const per=Math.max(0.04,introTime/allCommits.length);
|
|
38
|
+
const tlX=Math.floor(W()*0.15);
|
|
39
|
+
|
|
40
|
+
for(let cursor=0;cursor<allCommits.length;cursor++){
|
|
41
|
+
if(quit){clean();resolve();return}
|
|
42
|
+
const w=W(),h=H();
|
|
43
|
+
const rows=[];
|
|
44
|
+
|
|
45
|
+
// Header
|
|
46
|
+
rows.push(center(`${B}${FG(220)}β commitsmusic${R} ${D}git timeline${R}`,w));
|
|
47
|
+
rows.push(wfix(D+'β'.repeat(w)+R,w));
|
|
48
|
+
|
|
49
|
+
// Show all commits from 0..cursor, newest at bottom, scroll up
|
|
50
|
+
const start=0; // NO cap β show everything
|
|
51
|
+
const count=cursor+1;
|
|
52
|
+
// Position: newest commit fixed near bottom, older ones scroll off top
|
|
53
|
+
const bottomPad=4; // rows reserved for progress bar
|
|
54
|
+
const firstVisibleRow=2; // after header+sep
|
|
55
|
+
const lastVisibleRow=h-bottomPad-1;
|
|
56
|
+
const avail=lastVisibleRow-firstVisibleRow+1;
|
|
57
|
+
|
|
58
|
+
// Each commit takes 2 rows (text + vertical line)
|
|
59
|
+
// Newest commit is at lastVisibleRow
|
|
60
|
+
// Scroll: as count grows, commits push up
|
|
61
|
+
const newestRow=lastVisibleRow-1; // commit at this row
|
|
62
|
+
const startRow=newestRow-(count-1)*2;
|
|
63
|
+
|
|
64
|
+
for(let i=0;i<count;i++){
|
|
65
|
+
const ci=start+i;
|
|
66
|
+
const c=allCommits[ci];
|
|
67
|
+
const isCurrent=ci===cursor;
|
|
68
|
+
const row=startRow+i*2;
|
|
69
|
+
|
|
70
|
+
if(row<firstVisibleRow) continue; // scrolled off top
|
|
71
|
+
if(row>lastVisibleRow) continue; // below viewport
|
|
72
|
+
|
|
73
|
+
// Fill space before this row
|
|
74
|
+
while(rows.length<row) rows.push('');
|
|
75
|
+
|
|
76
|
+
const age=(cursor-ci)/Math.max(1,cursor); // 0=newest, 1=oldest
|
|
77
|
+
const bright=Math.floor(248-age*80); // fade older commits
|
|
78
|
+
const dot=isCurrent?B+FG(220)+'β'+R:D+FG(bright)+'β'+R;
|
|
79
|
+
const msg=c.msg.slice(0,w-tlX-25);
|
|
80
|
+
const msgColor=isCurrent?FG(226):FG(bright);
|
|
81
|
+
|
|
82
|
+
rows.push(`${wfix('',tlX)}${dot} ${D}${FG(240)}${isCurrent?'β£':'β'}${R} ${msgColor}${isCurrent?B:''}${msg}${R}`);
|
|
83
|
+
|
|
84
|
+
// Vertical connector below (if not last)
|
|
85
|
+
if(i<count-1&&row+1<=lastVisibleRow){
|
|
86
|
+
while(rows.length<row+1) rows.push('');
|
|
87
|
+
rows.push(`${wfix('',tlX)} ${D}${FG(240)}β${R}`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Fill to bottom
|
|
92
|
+
while(rows.length<h-3) rows.push('');
|
|
93
|
+
|
|
94
|
+
// Progress
|
|
95
|
+
const pct=(cursor+1)/allCommits.length;
|
|
96
|
+
const bw=Math.min(w-12,40);
|
|
97
|
+
const done=Math.floor(pct*bw);
|
|
98
|
+
rows.push(center(`${FG(220)}${'β'.repeat(done)}${FG(236)}${'β'.repeat(bw-done)}${R}`,w));
|
|
99
|
+
rows.push(center(`${D}${cursor+1}/${allCommits.length}${R}`,w));
|
|
100
|
+
|
|
101
|
+
process.stdout.write(SB+HOME+rows.join('\n')+'\x1b[J'+SE);
|
|
102
|
+
await sleep(per*1000);
|
|
103
|
+
}
|
|
104
|
+
if(quit){clean();resolve();return}
|
|
105
|
+
|
|
106
|
+
// βββ Cinematic transition: flash β blackout β reveal βββ
|
|
107
|
+
// 1. Flash: all commit dots light up
|
|
108
|
+
{
|
|
109
|
+
const w=W(),h=H();
|
|
110
|
+
const rows=[];
|
|
111
|
+
rows.push(center(`${B}${FG(220)}β commitsmusic${R} ${B}${FG(226)}${allCommits.length} commits loaded${R}`,w));
|
|
112
|
+
rows.push(wfix(D+'β'.repeat(w)+R,w));
|
|
113
|
+
const show=Math.min(allCommits.length,h-6);
|
|
114
|
+
for(let j=0;j<show;j++){
|
|
115
|
+
const c=allCommits[j];
|
|
116
|
+
rows.push(`${wfix('',tlX)} ${FG(220)}β${R} ${FG(226)}${c.msg.slice(0,w-tlX-20)}${R}`);
|
|
117
|
+
}
|
|
118
|
+
while(rows.length<h-2) rows.push('');
|
|
119
|
+
rows.push(center(`${FG(220)}βΆ${R}`,w));
|
|
120
|
+
process.stdout.write(SB+HOME+rows.join('\n')+'\x1b[J'+SE);
|
|
121
|
+
await sleep(400);
|
|
122
|
+
}
|
|
123
|
+
// 2. Blackout
|
|
124
|
+
{
|
|
125
|
+
const w=W(),h=H();
|
|
126
|
+
process.stdout.write(SB+HOME+Array(h).fill(wfix('',w)).join('\n')+'\x1b[J'+SE);
|
|
127
|
+
await sleep(200);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// βββ Piano Roll βββ
|
|
131
|
+
try{player=spawn('afplay',[wavPath],{stdio:'ignore'})}catch(_){}
|
|
132
|
+
const t0=Date.now();
|
|
133
|
+
let active=null;
|
|
134
|
+
|
|
135
|
+
const iv=setInterval(()=>{
|
|
136
|
+
if(quit){clean();resolve();clearInterval(iv);return}
|
|
137
|
+
const el=(Date.now()-t0)/1000;
|
|
138
|
+
if(el>totalTime+1.5){clean();resolve();clearInterval(iv);return}
|
|
139
|
+
|
|
140
|
+
const w=W(),h=H(),hitX=Math.floor(w*0.18);
|
|
141
|
+
const ROLL=Math.max(3,h-11),miLo=38,miHi=84,spc=2.2/(w-hitX-4);
|
|
142
|
+
|
|
143
|
+
for(const n of notes){if(n.t<=el)active=n;else break}
|
|
144
|
+
|
|
145
|
+
const rows=[];
|
|
146
|
+
rows.push(wfix(`${B}${FG(220)}β commitsmusic${R} ${FG(226)}${key.name} ${key.mode}${R} ${B}${nCommits}c${R} ${Math.round(totalTime)}s`,w));
|
|
147
|
+
rows.push(wfix(D+'β'.repeat(w)+R,w));
|
|
148
|
+
|
|
149
|
+
for(let r=0;r<ROLL;r++){
|
|
150
|
+
const midi=Math.round(miHi-r/(ROLL-1)*(miHi-miLo));
|
|
151
|
+
const isRoot=midi%12===0,isFifth=midi%12===7;
|
|
152
|
+
rows.push(isRoot?wfix(FG(238)+'Β·'+FG(234)+'Β·'.repeat(w-2)+R,w)
|
|
153
|
+
:isFifth?wfix(FG(234)+'Β·'.repeat(w)+R,w):wfix('',w));
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
for(const n of notes){
|
|
157
|
+
const dt=n.t-el;
|
|
158
|
+
let x=hitX+Math.round(dt/spc),dw=Math.max(1,Math.round(n.dur/spc));
|
|
159
|
+
if(x<hitX){dw-=(hitX-x);x=hitX}
|
|
160
|
+
if(x>=w||dw<1)continue;
|
|
161
|
+
const ww=Math.min(dw,w-x);
|
|
162
|
+
const r=Math.round((miHi-n.midi)/(miHi-miLo)*(ROLL-1));
|
|
163
|
+
if(r<0||r>=ROLL)continue;
|
|
164
|
+
const ri=2+r,raw=rows[ri].replace(/\x1b\[[^m]*m/g,'');
|
|
165
|
+
rows[ri]=wfix(raw.slice(0,x)+noteColor(n.midi)+'β'.repeat(ww)+R+raw.slice(x+ww),w);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
for(let r=0;r<ROLL;r++){
|
|
169
|
+
const ri=2+r,raw=rows[ri].replace(/\x1b\[[^m]*m/g,'');
|
|
170
|
+
rows[ri]=wfix(raw.slice(0,hitX)+B+FG(15)+BG(236)+'β'+R+raw.slice(hitX+1),w);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
rows.push(wfix(D+'β'.repeat(w)+R,w));
|
|
174
|
+
|
|
175
|
+
for(let i=0;i<6;i++){
|
|
176
|
+
let t='';
|
|
177
|
+
if(active){
|
|
178
|
+
if(i===0) t=` ${B}${FG(220)}${active.msg.slice(0,w-6)}`;
|
|
179
|
+
else if(i===1) t=` ${FG(248)}${D}by ${active.au}`;
|
|
180
|
+
else if(i===2&&active.files&&active.files.length)
|
|
181
|
+
t=` ${FG(242)}${active.files.slice(0,5).join(' Β· ').slice(0,w-6)}`;
|
|
182
|
+
}
|
|
183
|
+
rows.push(wfix(t+R,w));
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const pct=Math.min(1,el/totalTime),bw=w-18,f=Math.floor(pct*bw);
|
|
187
|
+
const ti=`${pad2(Math.floor(el/60))}:${pad2(Math.floor(el%60))} / ${pad2(Math.floor(totalTime/60))}:${pad2(Math.floor(totalTime%60))}`;
|
|
188
|
+
rows.push(` ${FG(220)}${'β'.repeat(f)}${D}${'β¬'.repeat(bw-f)}${R} ${FG(248)}${ti}${R}`);
|
|
189
|
+
|
|
190
|
+
process.stdout.write(SB+HOME+rows.join('\n')+'\x1b[J'+SE);
|
|
191
|
+
},67);
|
|
192
|
+
|
|
193
|
+
function clean(){
|
|
194
|
+
clearInterval(iv);if(player)player.kill();
|
|
195
|
+
process.stdin.setRawMode(wr);process.stdout.write(SH+AE);
|
|
196
|
+
}
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function wfix(s,W,ch=' '){const c=s.replace(/\x1b\[[^;]*m/g,'');return s+ch.repeat(Math.max(0,W-c.length))}
|
|
201
|
+
function pad2(n){return String(n).padStart(2,'0')}
|
|
202
|
+
function center(s,w){const c=s.replace(/\x1b\[[^;]*m/g,'');const p=Math.floor((w-c.length)/2);return ' '.repeat(Math.max(0,p))+s}
|
|
203
|
+
function sleep(ms){return new Promise(r=>setTimeout(r,ms))}
|
|
204
|
+
|
|
205
|
+
module.exports={playWithAnimation};
|
package/src/engine.js
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/** commitsmusic engine β parse commits, generate music, render WAV */
|
|
3
|
+
|
|
4
|
+
const { execSync } = require('child_process');
|
|
5
|
+
const fs = require('fs'), path = require('path');
|
|
6
|
+
const SR = 44100;
|
|
7
|
+
|
|
8
|
+
const SIN = new Float32Array(4096);
|
|
9
|
+
for(let i=0;i<4096;i++) SIN[i]=Math.sin(2*Math.PI*i/4096);
|
|
10
|
+
const sin=ph=>SIN[Math.min(4095,Math.floor((((ph%1)+1)%1)*4096))];
|
|
11
|
+
const m2f=m=>440*2**((m-69)/12);
|
|
12
|
+
|
|
13
|
+
// βββ Git Parse ββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
14
|
+
function parse(repo, n=30){
|
|
15
|
+
if(!fs.existsSync(path.join(repo,'.git'))) throw new Error(`not a git repository: ${repo}`);
|
|
16
|
+
const out=execSync(`git -C "${repo}" log --no-merges --format='%aI|%an|%s' --stat -n ${n}`,{encoding:'utf8',maxBuffer:10e6});
|
|
17
|
+
const cs=[];let c=null;
|
|
18
|
+
for(const l of out.split('\n')){
|
|
19
|
+
if(/^\d{4}-\d{2}-\d{2}T/.test(l)){if(c)cs.push(c);const p=l.split('|');c={d:p[0],au:p[1]||'unknown',m:p.slice(2).join('|')||'',i:0,x:0,files:[],diff:[]}}
|
|
20
|
+
else if(c){
|
|
21
|
+
const mi=l.match(/(\d+) insertion/), md=l.match(/(\d+) deletion/);
|
|
22
|
+
if(mi)c.i=+mi[1];if(md)c.x=+md[1];
|
|
23
|
+
// File line from --stat: " src/file.js | 5 +++"
|
|
24
|
+
const fm=l.match(/^\s+(.+?)\s+\|\s+\d+/);
|
|
25
|
+
if(fm)c.files.push(fm[1].trim());
|
|
26
|
+
if(c.diff.length<3) c.diff.push(l.trim());
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
if(c)cs.push(c);return cs.reverse();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// βββ Music Theory βββββββββββββββββββββββββββββββββββββββββββββ
|
|
33
|
+
const NM='C C# D Eb E F F# G Ab A Bb B'.split(' ');
|
|
34
|
+
const PM=[0,2,4,7,9], Pm=[0,3,5,7,10];
|
|
35
|
+
const KS_M=[6.35,2.23,3.48,2.33,4.38,4.09,2.52,5.19,2.39,3.66,2.29,2.88];
|
|
36
|
+
const KS_m=[6.33,2.68,3.52,5.38,2.60,3.53,2.54,4.75,3.98,2.69,3.34,3.17];
|
|
37
|
+
|
|
38
|
+
function detectKey(raw){
|
|
39
|
+
const d=new Float32Array(12);let t=0;
|
|
40
|
+
for(const n of raw){d[n.midi%12]+=n.w;t+=n.w}
|
|
41
|
+
for(let i=0;i<12;i++)d[i]/=t;
|
|
42
|
+
let bk=0,bc=-2,bm='major';
|
|
43
|
+
for(let k=0;k<12;k++){
|
|
44
|
+
for(const[m,ks]of[['major',KS_M],['minor',KS_m]]){
|
|
45
|
+
const p=ks.map((_,i)=>ks[(i-k+12)%12]);let n=0,d1=0,d2=0;
|
|
46
|
+
for(let i=0;i<12;i++){n+=(d[i]-1/12)*(p[i]-p.reduce((a,b)=>a+b)/12);d1+=(d[i]-1/12)**2;d2+=(p[i]-p.reduce((a,b)=>a+b)/12)**2}
|
|
47
|
+
const c=n/Math.sqrt(d1*d2);if(c>bc){bc=c;bk=k;bm=m}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return{ki:bk,mode:bm,name:NM[bk],pt:(bm==='major'?PM:Pm).map(d=>(bk+d)%12),corr:bc};
|
|
51
|
+
}
|
|
52
|
+
function snap(midi,pt){const pc=midi%12;let b=midi,bd=99;for(const p of pt){const d=Math.min(Math.abs(pc-p),12-Math.abs(pc-p));if(d<bd){bd=d;b=midi-pc+p}}return Math.max(40,Math.min(84,b))}
|
|
53
|
+
|
|
54
|
+
// βββ Melody βββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
55
|
+
function buildMelody(cs,key){
|
|
56
|
+
const pt=key.pt, mc=Math.max(1,...cs.map(c=>c.i+c.x));
|
|
57
|
+
const raw=cs.map(c=>({midi:snap(48+Math.round((c.i+c.x)/mc*31),pt),h:new Date(c.d).getHours(),w:(c.i+c.x)/mc,msg:c.m.slice(0,80),au:c.au||'unknown',files:c.files||[],diff:c.diff}));
|
|
58
|
+
|
|
59
|
+
let ci=0;for(let i=0;i<raw.length;i++)if(raw[i].w>raw[ci].w)ci=i;
|
|
60
|
+
|
|
61
|
+
// Voice leading
|
|
62
|
+
for(let i=1;i<raw.length;i++){const g=raw[i].midi-raw[i-1].midi;if(Math.abs(g)>7)raw[i].midi=snap(raw[i-1].midi+(g>0?5:-5),pt)}
|
|
63
|
+
for(let i=2;i<raw.length;i++){const g1=raw[i-1].midi-raw[i-2].midi,g2=raw[i].midi-raw[i-1].midi;if(Math.abs(g1)>=4)raw[i].midi=snap(raw[i-1].midi+(-Math.sign(g1)||-1)*2,pt);else if(Math.abs(g2)>=4&&Math.abs(g1)>=4&&g1*g2>0)raw[i].midi=snap(raw[i-1].midi-(g2>0?2:-2),pt)}
|
|
64
|
+
|
|
65
|
+
// Climax approach
|
|
66
|
+
if(ci>3){
|
|
67
|
+
const climMidi=snap(raw[ci].midi+12,pt);
|
|
68
|
+
for(let s=1;s<=3;s++){raw.splice(ci,0,{midi:snap(climMidi-4*s,pt),h:raw[ci].h,w:.3,msg:'',diff:[],tp:true,tn:'approach'});ci++}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Passing tones
|
|
72
|
+
const nts=[];
|
|
73
|
+
for(let i=0;i<raw.length;i++){
|
|
74
|
+
if(!raw[i].tp) nts.push({...raw[i],tp:false}); else nts.push({...raw[i],tp:true});
|
|
75
|
+
if(i<raw.length-1&&!raw[i].tp&&!raw[i+1].tp){
|
|
76
|
+
const g=raw[i+1].midi-raw[i].midi,ag=Math.abs(g);
|
|
77
|
+
if(ag>=3&&ag<=4) nts.push({midi:snap(Math.round((raw[i].midi+raw[i+1].midi)/2),pt),h:raw[i].h,w:.15,msg:'',diff:[],tp:true});
|
|
78
|
+
else if(ag>=5){const d=g>0?1:-1;nts.push({midi:snap(raw[i].midi+d*2,pt),h:raw[i].h,w:.12,msg:'',diff:[],tp:true});nts.push({midi:snap(raw[i].midi+d*4,pt),h:raw[i].h,w:.12,msg:'',diff:[],tp:true})}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Phrases
|
|
83
|
+
const phs=[];let ph=[nts[0]];for(let i=1;i<nts.length;i++){if(Math.abs(nts[i].h-nts[i-1].h)>4&&ph.length>=3){phs.push(ph);ph=[]}ph.push(nts[i])}if(ph.length)phs.push(ph);
|
|
84
|
+
|
|
85
|
+
// Rhythm
|
|
86
|
+
const BPM=80,beat=60/BPM;
|
|
87
|
+
const durPatterns=[
|
|
88
|
+
[2,0.5,1,0.5, 2,0.5,1,1],
|
|
89
|
+
[1.5,0.5,1,1, 1.5,0.5,1,1],
|
|
90
|
+
[1,0.5,0.5,1, 2,0.5,0.5,1],
|
|
91
|
+
[2,1,0.5,0.5, 1.5,1,0.5,1],
|
|
92
|
+
[1,1,1,0.5, 2,0.5,1,0.5],
|
|
93
|
+
];
|
|
94
|
+
|
|
95
|
+
const mel=[];let time=0;
|
|
96
|
+
for(let pi=0;pi<phs.length;pi++){
|
|
97
|
+
const phr=phs[pi], dp=durPatterns[pi%durPatterns.length];
|
|
98
|
+
let di=0;
|
|
99
|
+
for(let ni=0;ni<phr.length;ni++){
|
|
100
|
+
const n=phr[ni];
|
|
101
|
+
const durBeats=dp[di%dp.length];
|
|
102
|
+
let dur=beat*durBeats;
|
|
103
|
+
if(n.tp) dur=beat*0.5;
|
|
104
|
+
const vel=n.tp?.15:.55;
|
|
105
|
+
mel.push({freq:m2f(n.midi),midi:n.midi,t:Math.round(time*1e3)/1e3,dur:Math.round(dur*1e3)/1e3,vel,msg:n.msg,au:n.au||'unknown',files:n.files||[],diff:n.diff,tp:n.tp});
|
|
106
|
+
time+=dur;
|
|
107
|
+
if(!n.tp) di++;
|
|
108
|
+
}
|
|
109
|
+
time+=beat*0.5;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return{mel,totalTime:time,phs,ci,raw};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// βββ Chords (SCFA) ββββββββββββββββββββββββββββββββββββββββββββ
|
|
116
|
+
function buildAccompaniment(melody, totalTime, key){
|
|
117
|
+
const BPM=80,beat=60/BPM,bar=beat*4;
|
|
118
|
+
const pad=[], drums=[];
|
|
119
|
+
const cofDeg=[0,7,2,9,4,11,5,10,3,8,1,6];
|
|
120
|
+
const keyRoot=key.ki;
|
|
121
|
+
const allChords=[];
|
|
122
|
+
for(let ci=0;ci<12;ci++){
|
|
123
|
+
const root=(keyRoot+cofDeg[ci])%12;
|
|
124
|
+
const isMinor=[2,4,9].includes(cofDeg[ci]);
|
|
125
|
+
const third=(root+(isMinor?3:4))%12, fifth=(root+7)%12;
|
|
126
|
+
allChords.push({name:NM[root]+(isMinor?'m':''),root,third,fifth,cofPos:ci});
|
|
127
|
+
}
|
|
128
|
+
const symPositions=[0,3,6,9];
|
|
129
|
+
let t=0,lastCof=-1;
|
|
130
|
+
while(t<totalTime){
|
|
131
|
+
const sd=Math.min(bar*2,totalTime-t);
|
|
132
|
+
const melHere=melody.filter(n=>n.t>=t&&n.t<t+sd&&!n.tp);
|
|
133
|
+
const melPCs=new Set(melHere.map(n=>n.midi%12));
|
|
134
|
+
const melCount={};for(const pc of melPCs){melCount[pc]=melHere.filter(n=>n.midi%12===pc).length}
|
|
135
|
+
|
|
136
|
+
let bestCh=null,bestScore=-1;
|
|
137
|
+
for(const pos of symPositions){
|
|
138
|
+
const ch=allChords[pos];
|
|
139
|
+
if(pos===lastCof) continue;
|
|
140
|
+
let score=0;
|
|
141
|
+
for(const ct of[ch.root,ch.third,ch.fifth]){
|
|
142
|
+
if(melPCs.has(ct)) score+=2+(melCount[ct]||0);
|
|
143
|
+
if(melPCs.has((ct+2)%12)) score+=1;
|
|
144
|
+
}
|
|
145
|
+
if(score>bestScore){bestScore=score;bestCh=ch}
|
|
146
|
+
}
|
|
147
|
+
if(!bestCh){for(const ch of allChords){if(ch.cofPos===lastCof)continue;let score=0;for(const ct of[ch.root,ch.third,ch.fifth])if(melPCs.has(ct))score+=2;if(score>bestScore){bestScore=score;bestCh=ch}}}
|
|
148
|
+
if(!bestCh) bestCh=allChords[0];
|
|
149
|
+
lastCof=bestCh.cofPos;
|
|
150
|
+
|
|
151
|
+
const{r:rt,t:tt,f:ft}=bestCh;
|
|
152
|
+
for(const m of[ft+36,rt+48,tt+48,ft+48,rt+60,tt+60]) pad.push({freq:m2f(m),t,dur:sd,vel:.12,chordName:bestCh.name});
|
|
153
|
+
|
|
154
|
+
const steps16=Math.floor(sd/(beat/4));
|
|
155
|
+
for(let s=0;s<steps16;s++){
|
|
156
|
+
const dt=Math.round((t+s*beat/4)*1e3)/1e3, p=s%16;
|
|
157
|
+
if(p===0||p===8) drums.push({type:'kick',t:dt,dur:.12,vel:.4});
|
|
158
|
+
if(p===14&&s%32<16) drums.push({type:'kick',t:dt,dur:.06,vel:.15});
|
|
159
|
+
if(p===4||p===12) drums.push({type:'snare',t:dt,dur:.07,vel:.28});
|
|
160
|
+
if(p%2===0) drums.push({type:'hat',t:dt,dur:.03,vel:.1});
|
|
161
|
+
}
|
|
162
|
+
t+=sd;
|
|
163
|
+
}
|
|
164
|
+
return{pad,drums};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// βββ Synths ββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
168
|
+
function pianoS(freq,dur,vel,midi){
|
|
169
|
+
const fmComp=1+Math.max(0,(midi-50))*0.025;
|
|
170
|
+
const ts=Math.floor(Math.max(.02,dur)*SR),o=new Float32Array(ts);
|
|
171
|
+
for(const h of[{m:1,a:.8},{m:2,a:.5},{m:3,a:.25},{m:4,a:.12}]){
|
|
172
|
+
const f=freq*h.m*(1+h.m*.0002);
|
|
173
|
+
for(let i=0;i<ts;i++){const t=i/SR,e=t<.003?t/.003:(t<dur*.2?1-.65*(t-.003)/(dur*.2):(t<dur*.75?.35:.35*(1-(t-dur*.75)/(dur*.25))));o[i]+=sin(f*t)*h.a*e}
|
|
174
|
+
}
|
|
175
|
+
for(let i=0;i<Math.min(160,ts);i++)o[i]+=(Math.random()*2-1)*Math.exp(-i/150)*.05;
|
|
176
|
+
let rms=0;for(let i=0;i<ts;i++)rms+=o[i]*o[i];rms=Math.sqrt(rms/ts);
|
|
177
|
+
for(let i=0;i<ts;i++)o[i]*=(vel*fmComp)/(rms||1e-9);
|
|
178
|
+
return o;
|
|
179
|
+
}
|
|
180
|
+
function padS(freq,dur,vel){
|
|
181
|
+
const ts=Math.floor(dur*SR),o=new Float32Array(ts);
|
|
182
|
+
for(const h of[{m:1,a:.6},{m:2,a:.4},{m:3,a:.2}]){const f=freq*h.m;for(let i=0;i<ts;i++){const t=i/SR;o[i]+=sin(f*t)*h.a*Math.min(1,t*3)*Math.exp(-t*.15/dur)*vel}}
|
|
183
|
+
return o;
|
|
184
|
+
}
|
|
185
|
+
function kickS(d,v){const ts=Math.floor(d*SR),o=new Float32Array(ts);for(let i=0;i<ts;i++){const t=i/SR;o[i]=sin((55+130*Math.exp(-t*18))*t)*Math.exp(-t*8)*v}return o}
|
|
186
|
+
function snareS(d,v){const ts=Math.floor(d*SR),o=new Float32Array(ts);for(let i=0;i<ts;i++){const t=i/SR;o[i]=((Math.random()*2-1)*Math.exp(-t*25)*.35+sin(200*t)*Math.exp(-t*12)*.2)*Math.exp(-t*5)*v}return o}
|
|
187
|
+
function hatS(d,v){const ts=Math.floor(d*SR),o=new Float32Array(ts);for(let i=0;i<ts;i++)o[i]=(Math.random()*2-1)*Math.exp(-i/SR*70)*v*.2;return o}
|
|
188
|
+
function mix(t,b,o){for(let i=0;i<b.length&&o+i<t.length;i++){t[o+i]+=b[i];if(t[o+i]>3)t[o+i]=3;if(t[o+i]<-3)t[o+i]=-3}}
|
|
189
|
+
|
|
190
|
+
// βββ Render βββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
191
|
+
function renderAll(melody,acc,totalTime){
|
|
192
|
+
const TS=Math.floor(totalTime*SR*1.03),mx=new Float32Array(TS);
|
|
193
|
+
let climaxT=totalTime*.5, maxMidi=0;
|
|
194
|
+
for(const n of melody){if(!n.tp&&n.midi>maxMidi){maxMidi=n.midi;climaxT=n.t+n.dur*.5}}
|
|
195
|
+
for(const n of melody){
|
|
196
|
+
const buf=pianoS(n.freq,n.dur,n.vel,n.midi);
|
|
197
|
+
const distFromClimax=Math.abs(n.t-climaxT)/totalTime;
|
|
198
|
+
const dynArc=.55+.45*(1-Math.min(1,distFromClimax*2));
|
|
199
|
+
for(let i=0;i<buf.length;i++) buf[i]*=dynArc;
|
|
200
|
+
mix(mx, buf, Math.floor(n.t*SR));
|
|
201
|
+
}
|
|
202
|
+
for(const p of acc.pad) mix(mx, padS(p.freq,p.dur,p.vel), Math.floor(p.t*SR));
|
|
203
|
+
for(const d of acc.drums) mix(mx, d.type==='kick'?kickS(d.dur,d.vel):d.type==='snare'?snareS(d.dur,d.vel):hatS(d.dur,d.vel), Math.floor(d.t*SR));
|
|
204
|
+
for(let i=0;i<Math.floor(.08*SR)&&i<TS;i++)mx[i]*=i/(.08*SR);
|
|
205
|
+
for(let i=Math.max(0,TS-Math.floor(.12*SR));i<TS;i++)mx[i]*=(TS-i)/(.12*SR);
|
|
206
|
+
let pk=0;for(let i=0;i<TS;i++)pk=Math.max(pk,Math.abs(mx[i]));
|
|
207
|
+
if(pk>0){const ng=.9/pk;for(let i=0;i<TS;i++)mx[i]=Math.tanh(mx[i]*ng)}
|
|
208
|
+
return mx;
|
|
209
|
+
}
|
|
210
|
+
function writeWav(sp,fp){
|
|
211
|
+
const N=sp.length,b=Buffer.alloc(44+N*2);
|
|
212
|
+
b.write('RIFF',0);b.writeUInt32LE(36+N*2,4);b.write('WAVE',8);
|
|
213
|
+
b.write('fmt ',12);b.writeUInt32LE(16,16);b.writeUInt16LE(1,20);b.writeUInt16LE(1,22);
|
|
214
|
+
b.writeUInt32LE(SR,24);b.writeUInt32LE(SR*2,28);b.writeUInt16LE(2,32);b.writeUInt16LE(16,34);
|
|
215
|
+
b.write('data',36);b.writeUInt32LE(N*2,40);
|
|
216
|
+
for(let i=0;i<N;i++)b.writeInt16LE(Math.max(-32768,Math.min(32767,Math.floor(sp[i]*32767))),44+i*2);
|
|
217
|
+
fs.writeFileSync(fp,b);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// βββ Generate Pipeline ββββββββββββββββββββββββββββββββββββββββ
|
|
221
|
+
function generate(repo, n=30){
|
|
222
|
+
const cs=parse(repo, n);
|
|
223
|
+
if(!cs.length) throw new Error('no commits found');
|
|
224
|
+
const mc=Math.max(1,...cs.map(c=>c.i+c.x));
|
|
225
|
+
const rawForKey=cs.map(c=>({midi:48+Math.round((c.i+c.x)/mc*31),w:(c.i+c.x)/mc}));
|
|
226
|
+
const key=detectKey(rawForKey);
|
|
227
|
+
const{mel,totalTime}=buildMelody(cs,key);
|
|
228
|
+
const acc=buildAccompaniment(mel,totalTime,key);
|
|
229
|
+
const samples=renderAll(mel,acc,totalTime);
|
|
230
|
+
const chordSeq=[...new Set(acc.pad.map(p=>p.chordName))];
|
|
231
|
+
|
|
232
|
+
// Attach commit index to melody notes for animation
|
|
233
|
+
const commitNotes=mel.filter(n=>!n.tp);
|
|
234
|
+
|
|
235
|
+
return{samples,totalTime,key,mel,acc,chordSeq,commitNotes,cs};
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
module.exports={generate,writeWav,SR};
|
package/src/tui.js
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/** commitsmusic TUI β blessed selector β engine β animation */
|
|
3
|
+
|
|
4
|
+
const blessed=require('blessed');
|
|
5
|
+
const fs=require('fs'),path=require('path');
|
|
6
|
+
const{generate,writeWav}=require('./engine');
|
|
7
|
+
const{playWithAnimation}=require('./animation');
|
|
8
|
+
const{spawn}=require('child_process');
|
|
9
|
+
|
|
10
|
+
// βββ Find repos βββββββββββββββββββββββββββββββββββββββββββββ
|
|
11
|
+
function findRepos(){
|
|
12
|
+
const dirs=[];
|
|
13
|
+
function scan(dir,d=0){
|
|
14
|
+
if(d>3)return;
|
|
15
|
+
try{for(const e of fs.readdirSync(dir,{withFileTypes:true})){
|
|
16
|
+
if(e.name.startsWith('.'))continue;
|
|
17
|
+
const fp=path.join(dir,e.name);
|
|
18
|
+
if(e.isDirectory()){try{if(fs.existsSync(path.join(fp,'.git')))dirs.push(fp);else scan(fp,d+1)}catch(_){}}
|
|
19
|
+
}}catch(_){}
|
|
20
|
+
}
|
|
21
|
+
const home=process.env.HOME||'/Users/'+process.env.USER;
|
|
22
|
+
scan(home,1);scan(path.join(home,'Desktop'),2);
|
|
23
|
+
const cwd=process.cwd();
|
|
24
|
+
if(!dirs.includes(cwd)&&fs.existsSync(path.join(cwd,'.git')))dirs.unshift(cwd);
|
|
25
|
+
return dirs.length?dirs:[cwd];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const repos=findRepos();
|
|
29
|
+
const state={repo:repos[0],nCommits:30,animate:true};
|
|
30
|
+
|
|
31
|
+
function dim(s){return s.length>80?'...'+s.slice(-77):s}
|
|
32
|
+
|
|
33
|
+
// βββ Screen βββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
34
|
+
const screen=blessed.screen({smartCSR:true,title:'commitsmusic',fullUnicode:true});
|
|
35
|
+
|
|
36
|
+
const header=blessed.box({
|
|
37
|
+
top:0,left:0,width:'100%',height:2,
|
|
38
|
+
content:'{bold}{yellow-fg} βͺ commitsmusic{/yellow-fg}{/bold} {grey-fg}turn git history into music{/grey-fg}',
|
|
39
|
+
tags:true,
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
const repoList=blessed.list({
|
|
43
|
+
top:3,left:2,width:'100%-4',height:Math.min(9,repos.length+2),
|
|
44
|
+
label:'{bold} Repository {/bold}',
|
|
45
|
+
items:repos.map(r=>path.basename(r)+' '+dim(r)),
|
|
46
|
+
keys:true,vi:true,
|
|
47
|
+
style:{selected:{fg:'black',bg:'yellow'},item:{fg:'white',bg:'black'}},
|
|
48
|
+
border:{type:'line',fg:'cyan'},
|
|
49
|
+
tags:true,
|
|
50
|
+
});
|
|
51
|
+
repoList.on('select',(_,idx)=>{state.repo=repos[idx];updateInfo()});
|
|
52
|
+
|
|
53
|
+
const infoBox=blessed.box({
|
|
54
|
+
top:13,left:2,width:'100%-4',height:4,shrink:true,
|
|
55
|
+
border:{type:'line',fg:'cyan'},
|
|
56
|
+
tags:true,
|
|
57
|
+
});
|
|
58
|
+
function updateInfo(){
|
|
59
|
+
const bar='β'.repeat(Math.min(state.nCommits/10,15))+'β'.repeat(Math.max(0,15-Math.floor(state.nCommits/10)));
|
|
60
|
+
infoBox.setContent(
|
|
61
|
+
`{bold}Commits:{/bold} {yellow-fg}${state.nCommits}{/yellow-fg} ${bar}\n`+
|
|
62
|
+
`{bold}Animation:{/bold} ${state.animate?'{green-fg}ON{/green-fg}':'{red-fg}OFF{/red-fg}'}\n`+
|
|
63
|
+
`{cyan-fg}${dim(state.repo)}{/cyan-fg}`
|
|
64
|
+
);
|
|
65
|
+
screen.render();
|
|
66
|
+
}
|
|
67
|
+
updateInfo();
|
|
68
|
+
|
|
69
|
+
const helpBox=blessed.box({
|
|
70
|
+
bottom:3,left:0,width:'100%',height:2,
|
|
71
|
+
content:'{grey-fg} ββ repo ββ commits a toggle anim Enter GENERATE q quit{/grey-fg}',
|
|
72
|
+
tags:true,
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
const genBtn=blessed.box({
|
|
76
|
+
bottom:1,left:'center',width:24,height:1,
|
|
77
|
+
content:'{black-fg}{yellow-bg} βΆ GENERATE {/yellow-bg}{/black-fg}',
|
|
78
|
+
tags:true,align:'center',
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
screen.append(header);screen.append(repoList);screen.append(infoBox);
|
|
82
|
+
screen.append(helpBox);screen.append(genBtn);
|
|
83
|
+
|
|
84
|
+
screen.key(['left','h'],()=>{state.nCommits=Math.max(10,state.nCommits-10);updateInfo()});
|
|
85
|
+
screen.key(['right','l'],()=>{state.nCommits=Math.min(500,state.nCommits+10);updateInfo()});
|
|
86
|
+
screen.key(['a'],()=>{state.animate=!state.animate;updateInfo()});
|
|
87
|
+
screen.key(['q','C-c'],()=>{screen.destroy();process.exit(0)});
|
|
88
|
+
screen.key(['enter'],()=>runGenerate());
|
|
89
|
+
|
|
90
|
+
screen.render();repoList.focus();
|
|
91
|
+
|
|
92
|
+
// βββ Generate & Play ββββββββββββββββββββββββββββββββββββββββ
|
|
93
|
+
async function runGenerate(){
|
|
94
|
+
screen.destroy();
|
|
95
|
+
try{
|
|
96
|
+
console.log(`\n {bold}commitsmusic{/bold} β ${dim(state.repo)}\n`.replace(/\{(\/?)\w+(-\w+)?\}/g,''));
|
|
97
|
+
const res=generate(state.repo,state.nCommits);
|
|
98
|
+
const{key,totalTime,mel,chordSeq,samples,commitNotes,cs}=res;
|
|
99
|
+
console.log(` Key: ${key.name} ${key.mode} | ${cs.length}c β ${mel.length}n β ${Math.round(totalTime)}s`);
|
|
100
|
+
console.log(` Chords: ${chordSeq.join(' β ')}`);
|
|
101
|
+
const wavPath=path.join(state.repo,'.commitsmusic.wav');
|
|
102
|
+
writeWav(samples,wavPath);
|
|
103
|
+
console.log(` wav: ${wavPath}\n`);
|
|
104
|
+
|
|
105
|
+
if(state.animate){
|
|
106
|
+
console.log(' Playing... (q to quit)\n');
|
|
107
|
+
await new Promise(r=>setTimeout(r,800));
|
|
108
|
+
await playWithAnimation(samples,mel,commitNotes,totalTime,wavPath,key,cs.length);
|
|
109
|
+
}else{
|
|
110
|
+
console.log(' Playing...');
|
|
111
|
+
spawn('afplay',[wavPath],{stdio:'ignore'});
|
|
112
|
+
await new Promise(r=>setTimeout(r,totalTime*1000+500));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Save prompt
|
|
116
|
+
const rl=require('readline').createInterface({input:process.stdin,output:process.stdout});
|
|
117
|
+
rl.question('\n Save audio? [y/N/path]: ',(a)=>{
|
|
118
|
+
rl.close();
|
|
119
|
+
if(!a||a.toLowerCase()==='n'){fs.unlinkSync(wavPath);console.log(' Deleted.')}
|
|
120
|
+
else if(a.toLowerCase()==='y'){console.log(` Saved: ${wavPath}`)}
|
|
121
|
+
else{const d=path.resolve(a);fs.copyFileSync(wavPath,d);fs.unlinkSync(wavPath);console.log(` Saved: ${d}`)}
|
|
122
|
+
process.exit(0);
|
|
123
|
+
});
|
|
124
|
+
}catch(e){
|
|
125
|
+
console.error(` Error: ${e.message}`);
|
|
126
|
+
process.exit(1);
|
|
127
|
+
}
|
|
128
|
+
}
|