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 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
+ }
@@ -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
+ }