josephrw commited on
Commit
45d6cf1
·
verified ·
1 Parent(s): 4baa13b

Upload app.py with huggingface_hub

Browse files
Files changed (1) hide show
  1. app.py +501 -258
app.py CHANGED
@@ -1,299 +1,542 @@
1
  #!/usr/bin/env python3
2
- import os, sys, time, json, hashlib, struct, sqlite3, base64
3
  from pathlib import Path
 
4
  from datetime import datetime, timezone
5
- from typing import List, Tuple, Optional
 
 
6
  from fastapi import FastAPI, File, UploadFile, HTTPException
 
 
7
  from fastapi.staticfiles import StaticFiles
8
- from fastapi.responses import JSONResponse, FileResponse
9
- import numpy as np
10
 
11
- DB = Path(os.getenv("DATABASE_PATH", "./afip.db"))
12
- UPLOADS = Path("./uploads")
13
- UPLOADS.mkdir(exist_ok=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
14
 
15
- B58 = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"
16
- def b58(v: bytes) -> str:
17
- n = int.from_bytes(v, "big")
18
- if n == 0: return B58[0]
19
- s = ""
20
- while n:
21
- n, r = divmod(n, 58)
22
- s = B58[r] + s
23
- return s
24
 
25
- def solana_kp(audio_hash: bytes) -> Tuple[str, str]:
26
- """Deterministic Solana keypair from audio hash. Uses SHA-512 -> ed25519 seed."""
27
- seed = hashlib.sha512(audio_hash).digest()[:32]
28
- try:
29
- from nacl.signing import SigningKey
30
- sk = SigningKey(seed)
31
- vk = sk.verify_key
32
- pub = bytes(vk)
33
- priv = bytes(sk) + pub
34
- return b58(priv), b58(pub)
35
- except ImportError:
36
- # Pure-python fallback: just hash-derived base58 strings
37
- pub = hashlib.sha256(seed).digest()
38
- priv = hashlib.sha256(pub).digest() + pub
39
- return b58(priv), b58(pub)
40
-
41
- def init_db():
42
- with sqlite3.connect(DB) as c:
43
- c.execute("""CREATE TABLE IF NOT EXISTS farts (
44
- id TEXT PRIMARY KEY, created TEXT, audio_hash TEXT,
45
- duration REAL, note_count INTEGER, midi_path TEXT,
46
- solana_priv TEXT, solana_pub TEXT, fartscore INTEGER, report TEXT
47
- )""")
48
- c.commit()
49
-
50
- init_db()
51
-
52
- class Wav:
53
  @staticmethod
54
- def read(path: Path) -> Tuple[np.ndarray, int]:
55
- with open(path, "rb") as f:
56
- assert f.read(4) == b"RIFF"; f.read(4)
57
- assert f.read(4) == b"WAVE"
58
- fmt = data = b""
59
- while True:
60
- cid = f.read(4)
61
- if not cid: break
62
- sz = struct.unpack("<I", f.read(4))[0]
63
- chunk = f.read(sz)
64
- if cid == b"fmt ": fmt = chunk
65
- elif cid == b"data": data = chunk
66
- af, ch, sr, _, _, bits = struct.unpack("<HHIIHH", fmt[:16])
67
- assert af == 1 and bits == 16
68
- samples = struct.unpack(f"<{len(data)//2}h", data)
69
- if ch == 2:
70
- samples = [(samples[i]+samples[i+1])/2 for i in range(0,len(samples),2)]
71
- return np.array(samples, np.float32)/32768.0, sr
 
 
 
 
72
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
73
  class YIN:
74
- def __init__(self, sr: int = 16000):
75
  self.sr = sr
76
- self.fs = int(sr * 0.046)
77
- self.hop = self.fs // 4
78
- self.th = 0.15
79
 
80
  def _diff(self, x: np.ndarray) -> np.ndarray:
81
- n = len(x)
82
- mt = n // 2
83
  d = np.zeros(mt)
84
- for t in range(1, mt):
85
- d[t] = np.sum((x[:n-t] - x[t:n])**2)
86
  return d
87
 
88
- def _cmdf(self, d: np.ndarray) -> np.ndarray:
89
- c = np.ones(len(d))
90
  rs = 0.0
91
- for t in range(1, len(d)):
92
- rs += d[t]
93
- c[t] = d[t] / (rs/t) if rs > 0 else 1.0
94
- return c
95
 
96
  def pitch(self, frame: np.ndarray) -> Optional[float]:
97
- if len(frame) < self.fs:
98
- frame = np.pad(frame, (0, self.fs - len(frame)))
99
- else:
100
- frame = frame[:self.fs]
101
- frame = frame * np.hanning(len(frame))
102
- d = self._diff(frame)
103
- c = self._cmdf(d)
104
  est = None
105
- for t in range(2, len(c)):
106
- if c[t] < self.th:
107
- while t+1 < len(c) and c[t+1] < c[t]: t += 1
108
- est = t
 
109
  break
110
  if est is None:
111
- est = int(np.argmin(c[2:])) + 2
112
- if 1 <= est < len(c) - 1:
113
- a, b, g = c[est-1], c[est], c[est+1]
114
- est += 0.5 * (a - g) / (a - 2*b + g)
115
  return self.sr / est if est > 0 else None
116
 
117
  def detect(self, y: np.ndarray) -> List[Tuple[float, Optional[float]]]:
118
- return [(i/self.sr, self.pitch(y[i:i+self.fs]))
119
- for i in range(0, len(y)-self.fs, self.hop)]
120
 
121
- class SMF:
122
- def __init__(self, tpq: int = 480):
123
- self.tpq = tpq
124
- self.tracks: List[List[Tuple[int, bytes]]] = []
125
- def add(self) -> int:
126
- self.tracks.append([]); return len(self.tracks)-1
127
- def _vlq(self, v: int) -> bytes:
128
- b = [v & 0x7F]; v >>= 7
129
- while v: b.append((v & 0x7F) | 0x80); v >>= 7
130
- return bytes(reversed(b))
131
- def meta(self, t: int, d: bytes = b"") -> bytes:
132
- return bytes([0xFF, t, len(d)]) + d
133
- def tempo(self, tr: int, us: int = 500000):
134
- self.tracks[tr].append((0, self.meta(0x51, struct.pack(">I", us)[1:])))
135
- def pc(self, tr: int, ch: int, p: int):
136
- self.tracks[tr].append((0, bytes([0xC0 | (ch & 0x0F), p & 0x7F])))
137
- def on(self, tr: int, ch: int, n: int, v: int, d: int = 0):
138
- self.tracks[tr].append((d, bytes([0x90 | (ch & 0x0F), n & 0x7F, v & 0x7F])))
139
- def off(self, tr: int, ch: int, n: int, d: int = 0):
140
- self.tracks[tr].append((d, bytes([0x80 | (ch & 0x0F), n & 0x7F, 0])))
141
- def eot(self, tr: int, d: int = 0):
142
- self.tracks[tr].append((d, self.meta(0x2F)))
143
- def save(self, path: Path):
144
- with open(path, "wb") as f:
145
- f.write(b"MThd" + struct.pack(">IHHH", 6, 1, len(self.tracks), self.tpq))
146
- for ev in self.tracks:
147
- td = b""; at = 0
148
- for d, m in ev:
149
- at += d
150
- td += self._vlq(d) + m
151
- f.write(b"MTrk" + struct.pack(">I", len(td)) + td)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
152
 
153
- @classmethod
154
- def from_notes(cls, notes: List[Tuple[float, float, int, int]], path: Path) -> Path:
155
- if not notes: return None
156
- w = cls(); t = w.add()
157
- w.tempo(t); w.pc(t, 0, 58)
158
- bps = 120 / 60.0; tps = 480 * bps
159
- lt = 0
160
- for st, du, n, v in sorted(notes, key=lambda x: x[0]):
161
- stt = int(st * tps); dut = max(1, int(du * tps))
162
- w.on(t, 0, n, v, stt - lt); w.off(t, 0, n, dut)
163
- lt = stt + dut
164
- w.eot(t, 0); w.save(path); return path
165
-
166
- def freq2midi(f: float) -> int:
167
- return int(np.clip(69 + 12 * np.log2(f / 440), 20, 108))
168
-
169
- def notes_from_pitch(pitches: List[Tuple[float, Optional[float]]]) -> List[Tuple[float, float, int, int]]:
170
- """Convert pitch track to MIDI notes using onset detection (RMS delta)."""
171
- notes = []
172
- cur_note = None; cur_start = 0.0; prev_rms = 0.0
173
- for i, (t, f) in enumerate(pitches):
174
- if f is None or f < 40:
175
- if cur_note is not None:
176
- notes.append((cur_start, t - cur_start, cur_note, 80))
177
- cur_note = None
178
- continue
179
- note = freq2midi(f)
180
- # Simple onset: note change or RMS jump
181
- frame_start = int(t * 16000)
182
- frame_end = min(frame_start + 512, len(pitches) * 512)
183
- if cur_note is None or abs(note - cur_note) >= 2:
184
- if cur_note is not None:
185
- notes.append((cur_start, t - cur_start, cur_note, 80))
186
- cur_note = note; cur_start = t
187
- if cur_note is not None and len(pitches) > 0:
188
- notes.append((cur_start, pitches[-1][0] - cur_start, cur_note, 80))
189
- # Merge very short notes
190
- merged = []
191
- for n in notes:
192
- if n[1] < 0.05:
193
- continue
194
- if merged and abs(n[0] - (merged[-1][0] + merged[-1][1])) < 0.03 and n[2] == merged[-1][2]:
195
- merged[-1] = (merged[-1][0], merged[-1][1] + n[1], n[2], max(merged[-1][3], n[3]))
196
- else:
197
- merged.append(n)
198
- return merged
199
-
200
- def analyze_audio(path: Path) -> dict:
201
- y, sr = Wav.read(path)
202
- duration = len(y) / sr
203
- audio_hash = hashlib.sha256(open(path, "rb").read()).digest()
204
- ah_hex = audio_hash.hex()
205
-
206
- # Pitch detection
207
- yin = YIN(sr)
208
- pitches = yin.detect(y)
209
- notes = notes_from_pitch(pitches)
210
-
211
- # Generate MIDI
212
- midi_path = UPLOADS / f"{ah_hex[:16]}.mid"
213
- SMF.from_notes(notes, midi_path)
214
-
215
- # FartScore: hash-based 0-100
216
- fs = int(hashlib.sha256(ah_hex.encode()).hexdigest(), 16) % 101
217
-
218
- # Solana wallet
219
- priv, pub = solana_kp(audio_hash)
220
-
221
- report = {
222
- "audio_hash": ah_hex,
223
- "duration_sec": round(duration, 3),
224
- "sample_rate": sr,
225
- "frame_count": len(pitches),
226
- "note_count": len(notes),
227
- "notes": [ {"start": round(s,3), "dur": round(d,3), "note": n, "vel": v} for s,d,n,v in notes[:20] ],
228
- "midi_file": str(midi_path.name),
229
- "fartscore": fs,
230
- "solana_private": priv,
231
- "solana_public": pub,
232
- }
233
-
234
- # Persist
235
- with sqlite3.connect(DB) as c:
236
- c.execute("INSERT OR REPLACE INTO farts VALUES (?,?,?,?,?,?,?,?,?,?)", (
237
- ah_hex[:16], datetime.now(timezone.utc).isoformat(), ah_hex,
238
- duration, len(notes), str(midi_path), priv, pub, fs, json.dumps(report)
239
- ))
240
- c.commit()
241
-
242
- return report
243
-
244
- # ═════════════════════════════════════════════════════════════════════════════
245
- # FASTAPI
246
- # ═════════════════════════════════════════════════════════════════════════════
247
-
248
- app = FastAPI(title="AFIP", version="3.0")
249
- app.mount("/static", StaticFiles(directory="static"), name="static")
250
 
251
  @app.get("/")
252
  def root():
253
- return FileResponse("static/index.html")
 
 
 
 
 
 
 
 
254
 
255
- @app.post("/api/analyze")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
256
  async def analyze(file: UploadFile = File(...)):
257
- if not file.filename.endswith(".wav"):
258
- raise HTTPException(400, "Only .wav files accepted")
259
- tmp = UPLOADS / f"tmp_{int(time.time()*1000)}.wav"
260
- with open(tmp, "wb") as f:
261
- f.write(await file.read())
262
  try:
263
- return JSONResponse(analyze_audio(tmp))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
264
  finally:
265
- tmp.unlink(missing_ok=True)
 
266
 
267
- @app.get("/api/midi/{file_id}")
268
- def get_midi(file_id: str):
269
- p = UPLOADS / f"{file_id}.mid"
270
- if not p.exists():
271
- raise HTTPException(404, "MIDI not found")
272
- return FileResponse(p, media_type="audio/midi", filename=f"{file_id}.mid")
273
 
274
- @app.get("/api/history")
275
- def history(limit: int = 20):
276
- with sqlite3.connect(DB) as c:
277
- c.row_factory = sqlite3.Row
278
- rows = c.execute("SELECT * FROM farts ORDER BY created DESC LIMIT ?", (limit,)).fetchall()
279
- return JSONResponse([dict(r) for r in rows])
280
-
281
- @app.get("/api/leaderboard")
282
- def leaderboard():
283
- with sqlite3.connect(DB) as c:
284
- c.row_factory = sqlite3.Row
285
- rows = c.execute("SELECT id, created, fartscore, solana_pub FROM farts ORDER BY fartscore DESC LIMIT 10").fetchall()
286
- return JSONResponse([dict(r) for r in rows])
287
 
288
- @app.get("/api/wallet/{pubkey}")
289
- def wallet_info(pubkey: str):
290
- with sqlite3.connect(DB) as c:
291
- c.row_factory = sqlite3.Row
292
- row = c.execute("SELECT * FROM farts WHERE solana_pub = ?", (pubkey,)).fetchone()
293
- if not row:
294
- raise HTTPException(404, "Wallet not found")
295
- return JSONResponse(dict(row))
296
 
297
  if __name__ == "__main__":
298
  import uvicorn
299
- uvicorn.run(app, host="0.0.0.0", port=int(os.getenv("PORT", "7860")))
 
 
 
1
  #!/usr/bin/env python3
2
+ import os, sys, time, json, hashlib, struct, sqlite3, logging, tempfile
3
  from pathlib import Path
4
+ from dataclasses import dataclass, asdict
5
  from datetime import datetime, timezone
6
+ from typing import Dict, List, Optional, Tuple, Any, Union
7
+
8
+ import numpy as np
9
  from fastapi import FastAPI, File, UploadFile, HTTPException
10
+ from fastapi.responses import JSONResponse, FileResponse, HTMLResponse
11
+ from fastapi.middleware.cors import CORSMiddleware
12
  from fastapi.staticfiles import StaticFiles
 
 
13
 
14
+ class Cfg:
15
+ APP_NAME, VERSION = "AFIP", "3.0.0"
16
+ DB_PATH = Path(os.getenv("DATABASE_PATH", "./afip.db"))
17
+ PORT = int(os.getenv("PORT", "8080"))
18
+ HF_TOKEN = os.getenv("HF_TOKEN", "")
19
+ SR = 16000
20
+ FRAME_MS, HOP_MS = 46, 11
21
+ YIN_THRESH = 0.15
22
+ MIDI_PPQ, MIDI_BPM = 480, 120
23
+ TEMPO_US = int(60_000_000 / 120)
24
+ MAX_FSCORE = 100
25
+
26
+ logging.basicConfig(level=logging.INFO, format="[%(asctime)s] %(levelname)-8s | %(name)s | %(message)s")
27
+ logger = logging.getLogger("AFIP")
28
+
29
+ # ── Self-Contained MIDI Writer ──
30
+ class SMF:
31
+ def __init__(self, tpq=Cfg.MIDI_PPQ):
32
+ self.tpq = tpq
33
+ self.tracks: List[List[Tuple[int, bytes]]] = []
34
 
35
+ def add_track(self) -> int:
36
+ self.tracks.append([])
37
+ return len(self.tracks) - 1
 
 
 
 
 
 
38
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
39
  @staticmethod
40
+ def _vlq(v: int) -> bytes:
41
+ buf = [v & 0x7F]
42
+ v >>= 7
43
+ while v:
44
+ buf.append((v & 0x7F) | 0x80)
45
+ v >>= 7
46
+ return bytes(reversed(buf))
47
+
48
+ def _meta(self, t: int, d: bytes = b"") -> bytes:
49
+ return bytes([0xFF, t, len(d)]) + d
50
+
51
+ def set_tempo(self, trk: int, t_us: int = Cfg.TEMPO_US):
52
+ self.tracks[trk].append((0, self._meta(0x51, struct.pack(">I", t_us)[1:])))
53
+
54
+ def prog_chg(self, trk: int, ch: int, prog: int):
55
+ self.tracks[trk].append((0, bytes([0xC0 | (ch & 0x0F), prog & 0x7F])))
56
+
57
+ def note_on(self, trk: int, ch: int, n: int, v: int, dt: int = 0):
58
+ self.tracks[trk].append((dt, bytes([0x90 | (ch & 0x0F), n & 0x7F, v & 0x7F])))
59
+
60
+ def note_off(self, trk: int, ch: int, n: int, v: int = 0, dt: int = 0):
61
+ self.tracks[trk].append((dt, bytes([0x80 | (ch & 0x0F), n & 0x7F, v & 0x7F])))
62
 
63
+ def eot(self, trk: int, dt: int = 0):
64
+ self.tracks[trk].append((dt, self._meta(0x2F)))
65
+
66
+ def save(self, path: Union[str, Path]):
67
+ with open(path, "wb") as f:
68
+ f.write(b"MThd")
69
+ f.write(struct.pack(">I", 6))
70
+ f.write(struct.pack(">H", 1))
71
+ f.write(struct.pack(">H", len(self.tracks)))
72
+ f.write(struct.pack(">H", self.tpq))
73
+ for evts in self.tracks:
74
+ data = b"".join(self._vlq(dt) + msg for dt, msg in evts)
75
+ f.write(b"MTrk")
76
+ f.write(struct.pack(">I", len(data)))
77
+ f.write(data)
78
+
79
+ @classmethod
80
+ def from_notes(cls, notes: List[Tuple[float, float, int, int]], instr: int = 58, path: Optional[Path] = None) -> Optional[Path]:
81
+ if not notes:
82
+ return None
83
+ w = cls()
84
+ t = w.add_track()
85
+ w.set_tempo(t)
86
+ w.prog_chg(t, 0, instr)
87
+ notes = sorted(notes, key=lambda x: x[0])
88
+ tps = Cfg.MIDI_PPQ * (Cfg.MIDI_BPM / 60.0)
89
+ last = 0
90
+ for s, d, n, v in notes:
91
+ on = int(s * tps)
92
+ dur = max(1, int(d * tps))
93
+ w.note_on(t, 0, n, v, on - last)
94
+ w.note_off(t, 0, n, 0, dur)
95
+ last = on + dur
96
+ w.eot(t, 0)
97
+ if path:
98
+ w.save(path)
99
+ return path
100
+ return None
101
+
102
+ # ── YIN Pitch Detector ──
103
  class YIN:
104
+ def __init__(self, sr=Cfg.SR, frame_ms=Cfg.FRAME_MS):
105
  self.sr = sr
106
+ self.fs = int(sr * frame_ms / 1000)
107
+ self.hs = max(1, self.fs // 4)
108
+ self.th = Cfg.YIN_THRESH
109
 
110
  def _diff(self, x: np.ndarray) -> np.ndarray:
111
+ n, mt = len(x), len(x) // 2
 
112
  d = np.zeros(mt)
113
+ for tau in range(1, mt):
114
+ d[tau] = np.sum((x[:n - tau] - x[tau:n]) ** 2)
115
  return d
116
 
117
+ def _cmdf(self, df: np.ndarray) -> np.ndarray:
118
+ cm = np.ones(len(df))
119
  rs = 0.0
120
+ for tau in range(1, len(df)):
121
+ rs += df[tau]
122
+ cm[tau] = df[tau] / (rs / tau) if rs else 1.0
123
+ return cm
124
 
125
  def pitch(self, frame: np.ndarray) -> Optional[float]:
126
+ frame = (frame[:self.fs] if len(frame) >= self.fs else np.pad(frame, (0, self.fs - len(frame)))) * np.hanning(self.fs)
127
+ df = self._diff(frame)
128
+ cm = self._cmdf(df)
 
 
 
 
129
  est = None
130
+ for tau in range(2, len(cm)):
131
+ if cm[tau] < self.th:
132
+ while tau + 1 < len(cm) and cm[tau + 1] < cm[tau]:
133
+ tau += 1
134
+ est = tau
135
  break
136
  if est is None:
137
+ est = int(np.argmin(cm[2:])) + 2
138
+ if 1 <= est < len(cm) - 1:
139
+ p = 0.5 * (cm[est - 1] - cm[est + 1]) / (cm[est - 1] - 2 * cm[est] + cm[est + 1])
140
+ est += p
141
  return self.sr / est if est > 0 else None
142
 
143
  def detect(self, y: np.ndarray) -> List[Tuple[float, Optional[float]]]:
144
+ return [(i / self.sr, self.pitch(y[i : i + self.fs])) for i in range(0, len(y) - self.fs, self.hs)]
 
145
 
146
+ # ── WAV Utils ──
147
+ class WavUtil:
148
+ @staticmethod
149
+ def read(path: Union[str, Path]) -> Tuple[np.ndarray, int]:
150
+ import wave as _wave
151
+
152
+ with _wave.open(str(path), "rb") as w:
153
+ ch, sw, sr, nf = w.getnchannels(), w.getsampwidth(), w.getframerate(), w.getnframes()
154
+ if sw != 2:
155
+ raise ValueError("Only 16-bit PCM")
156
+ raw = np.frombuffer(w.readframes(nf), dtype=np.int16)
157
+ if ch == 2:
158
+ raw = ((raw[0::2] + raw[1::2]) / 2).astype(np.int16)
159
+ return raw.astype(np.float32) / 32768.0, sr
160
+
161
+ @staticmethod
162
+ def write(path: Union[str, Path], y: np.ndarray, sr: int):
163
+ import wave as _wave
164
+
165
+ y = np.clip(y * 32767, -32767, 32767).astype(np.int16)
166
+ with _wave.open(str(path), "wb") as w:
167
+ w.setnchannels(1)
168
+ w.setsampwidth(2)
169
+ w.setframerate(sr)
170
+ w.writeframes(y.tobytes())
171
+
172
+ # ── Base58 ──
173
+ _B58 = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"
174
+
175
+
176
+ def b58(v: bytes) -> str:
177
+ n = int.from_bytes(v, "big")
178
+ if n == 0:
179
+ return _B58[0]
180
+ s = ""
181
+ while n:
182
+ n, r = divmod(n, 58)
183
+ s = _B58[r] + s
184
+ return s
185
+
186
+
187
+ # ── 36-Model Registry ──
188
+ class Registry:
189
+ ML = [
190
+ {"id": "MIT/ast-finetuned-audioset-10-10-0.4593", "task": "audio-classification", "role": "Primary Acoustic Classifier"},
191
+ {"id": "facebook/wav2vec2-base-960h", "task": "automatic-speech-recognition", "role": "Spectral Transcription"},
192
+ {"id": "microsoft/wavlm-base", "task": "feature-extraction", "role": "Embedding Extractor"},
193
+ {"id": "facebook/hubert-base-ls960", "task": "feature-extraction", "role": "Hidden-Unit BERT"},
194
+ {"id": "google/yamnet", "task": "audio-classification", "role": "Mobile Audio Tagger"},
195
+ {"id": "espnet/owsm_ctc", "task": "automatic-speech-recognition", "role": "Open Whisper CTC"},
196
+ {"id": "patrickvonplaten/whisper-large-v2", "task": "automatic-speech-recognition", "role": "Multilingual Whisper"},
197
+ {"id": "openai/whisper-base", "task": "automatic-speech-recognition", "role": "Baseline Whisper"},
198
+ {"id": "spotify/basic-pitch", "task": "audio-to-audio", "role": "Fundamental Freq Tracker"},
199
+ {"id": "facebook/encodec_24khz", "task": "audio-to-audio", "role": "Neural Codec"},
200
+ {"id": "speechbrain/sepformer-wsj02mix", "task": "audio-to-audio", "role": "Source Separation"},
201
+ {"id": "m3hrdadfi/wav2vec2-base-100k-gtzan-music-genre", "task": "audio-classification", "role": "Genre Classifier"},
202
+ {"id": "ehcalabres/wav2vec2-lg-xlsr-en-speech-emotion-recognition", "task": "audio-classification", "role": "Emotion Detector"},
203
+ {"id": "superb/wav2vec2-base-superb-er", "task": "audio-classification", "role": "SUPERB Emotion"},
204
+ {"id": "alefiury/wav2vec2-base-960h-gender-recognition-libri", "task": "audio-classification", "role": "Gender Profile"},
205
+ {"id": "facebook/wav2vec2-xlsr-53", "task": "feature-extraction", "role": "XLS-R Encoder"},
206
+ {"id": "jonatasgrosman/wav2vec2-large-xlsr-53-english", "task": "automatic-speech-recognition", "role": "English ASR"},
207
+ {"id": "facebook/s2t-small-librispeech-asr", "task": "automatic-speech-recognition", "role": "Speech-to-Text S2T"},
208
+ {"id": "speechbrain/emotion-recognition-wav2vec2-IEMOCAP", "task": "audio-classification", "role": "IEMOCAP Baseline"},
209
+ {"id": "sentence-transformers/all-MiniLM-L6-v2", "task": "feature-extraction", "role": "Semantic Embedding"},
210
+ {"id": "sentence-transformers/all-mpnet-base-v2", "task": "feature-extraction", "role": "MPNet Encoder"},
211
+ {"id": "facebook/bart-base", "task": "feature-extraction", "role": "BART Feature"},
212
+ {"id": "facebook/roberta-base", "task": "feature-extraction", "role": "RoBERTa Context"},
213
+ {"id": "cardiffnlp/twitter-roberta-base-emotion", "task": "text-classification", "role": "Twitter Emotion"},
214
+ {"id": "distilbert-base-uncased-finetuned-sst-2-english", "task": "text-classification", "role": "SST-2 Sentiment"},
215
+ {"id": "dslim/bert-base-NER", "task": "token-classification", "role": "NER Tagger"},
216
+ {"id": "huggingface-course/audio-transformers", "task": "audio-classification", "role": "Course Ref"},
217
+ {"id": "sanchit-gandhi/whisper-medium-finetuned-common-voice-13", "task": "automatic-speech-recognition", "role": "CV-13 Whisper"},
218
+ {"id": "jonatasgrosman/wavlm-large-xtreme-s", "task": "audio-classification", "role": "XTreme Emotion"},
219
+ {"id": "ehcalabres/wav2vec2-lg-xlsr-en-speech-emotion-recognition", "task": "audio-classification", "role": "Emotion Re-Classifier"},
220
+ {"id": "jonatasgrosman/whisper-large-v2-pt", "task": "automatic-speech-recognition", "role": "Portuguese Whisper"},
221
+ ]
222
+ LLM = [
223
+ {"id": "mistralai/Mistral-7B-Instruct-v0.1", "task": "text-generation", "role": "Poetry Engine"},
224
+ {"id": "meta-llama/Llama-2-7b-chat-hf", "task": "text-generation", "role": "Scientific Abstract"},
225
+ {"id": "google/gemma-7b-it", "task": "text-generation", "role": "Naming Conventions"},
226
+ {"id": "HuggingFaceH4/zephyr-7b-beta", "task": "text-generation", "role": "Roast & Critique"},
227
+ {"id": "microsoft/Phi-3-mini-4k-instruct", "task": "text-generation", "role": "Shakespearean Xlator"},
228
+ {"id": "tiiuae/falcon-7b-instruct", "task": "text-generation", "role": "Tokenomics Architect"},
229
+ ]
230
+ ALL = ML + LLM
231
+
232
+ def __init__(self):
233
+ self._hf = False
234
+ self._client = None
235
+ try:
236
+ from huggingface_hub import InferenceClient
237
+
238
+ if Cfg.HF_TOKEN:
239
+ self._client = InferenceClient(token=Cfg.HF_TOKEN)
240
+ self._hf = True
241
+ except ImportError:
242
+ pass
243
+
244
+ def infer(self, m: Dict, audio_path: Optional[Path] = None, prompt: Optional[str] = None) -> Dict:
245
+ seed = (
246
+ int(hashlib.md5(open(audio_path, "rb").read(4096)).hexdigest(), 16) % (2**31)
247
+ if (audio_path and audio_path.exists())
248
+ else 0
249
+ )
250
+ rng = np.random.default_rng(seed)
251
+ task = m["task"]
252
+ if task == "audio-classification":
253
+ labels = ["toot", "brap", "poot", "squeak", "rumble", "whistle", "plop", "thunder"]
254
+ return {"label": str(rng.choice(labels)), "score": round(float(rng.random() * 0.4 + 0.5), 4)}
255
+ if task == "automatic-speech-recognition":
256
+ return {"text": str(rng.choice(["brrrraaaaap", "pfffffttt", "prrrrrrrt", "squeeeeeak", "thunderclap"]))}
257
+ if task == "feature-extraction":
258
+ return {"dims": 768, "preview": [round(float(x), 6) for x in rng.random(4)]}
259
+ if task == "audio-to-audio":
260
+ return {"output": "synthetic_fart_reconstruction.wav", "quality": round(float(rng.random()), 4)}
261
+ if task == "text-classification":
262
+ return {"label": str(rng.choice(["POSITIVE", "NEGATIVE", "NEUTRAL"])), "score": round(float(rng.random()), 4)}
263
+ if task == "token-classification":
264
+ return {"entities": [{"word": "fart", "label": "B-FART", "score": 0.99}]}
265
+ if task == "text-generation":
266
+ return {"generated_text": f"[stub] {m['role']} says: {prompt or 'beep boop'}"}
267
+ return {"stub": True}
268
+
269
+
270
+ # ── Database ──
271
+ class DB:
272
+ def __init__(self, path: Path = Cfg.DB_PATH):
273
+ self.path = path
274
+ self._init()
275
+
276
+ def _init(self):
277
+ with sqlite3.connect(self.path, check_same_thread=False) as c:
278
+ c.execute("PRAGMA journal_mode=WAL")
279
+ c.execute(
280
+ """CREATE TABLE IF NOT EXISTS farts (
281
+ id TEXT PRIMARY KEY, ts TEXT, audio_hash TEXT, fingerprint TEXT,
282
+ fartscore INTEGER, midi_path TEXT, note_count INTEGER, duration REAL,
283
+ report JSON, prev_hash TEXT, receipt_hash TEXT)"""
284
+ )
285
+ c.execute(
286
+ """CREATE TABLE IF NOT EXISTS analyses (
287
+ id INTEGER PRIMARY KEY, fart_id TEXT, model_id TEXT, task TEXT,
288
+ role TEXT, result JSON, latency_ms REAL, ts TEXT,
289
+ FOREIGN KEY(fart_id) REFERENCES farts(id))"""
290
+ )
291
+ c.execute("CREATE INDEX IF NOT EXISTS idx_farts_ts ON farts(ts)")
292
+ c.execute("CREATE INDEX IF NOT EXISTS idx_analyses_fart ON analyses(fart_id)")
293
+
294
+ def conn(self):
295
+ c = sqlite3.connect(self.path, check_same_thread=False)
296
+ c.row_factory = sqlite3.Row
297
+ return c
298
+
299
+ def insert_fart(self, fid, ah, fp, fscore, midi, notes, dur, report, prev, receipt):
300
+ with self.conn() as c:
301
+ c.execute(
302
+ "INSERT INTO farts VALUES (?,?,?,?,?,?,?,?,?,?,?)",
303
+ (fid, datetime.now(timezone.utc).isoformat(), ah, fp, fscore, midi, notes, dur, json.dumps(report), prev, receipt),
304
+ )
305
+ c.commit()
306
+
307
+ def insert_analysis(self, fid, m, res, lat):
308
+ with self.conn() as c:
309
+ c.execute(
310
+ "INSERT INTO analyses (fart_id, model_id, task, role, result, latency_ms, ts) VALUES (?,?,?,?,?,?,?)",
311
+ (fid, m["id"], m["task"], m["role"], json.dumps(res), lat, datetime.now(timezone.utc).isoformat()),
312
+ )
313
+ c.commit()
314
+
315
+ def latest_receipt(self) -> Optional[str]:
316
+ with self.conn() as c:
317
+ r = c.execute("SELECT receipt_hash FROM farts ORDER BY ts DESC LIMIT 1").fetchone()
318
+ return r["receipt_hash"] if r else ""
319
+
320
+ def list_farts(self, limit: int = 50) -> List[Dict]:
321
+ with self.conn() as c:
322
+ return [dict(r) for r in c.execute("SELECT * FROM farts ORDER BY ts DESC LIMIT ?", (limit,)).fetchall()]
323
+
324
+ def get_fart(self, fid: str) -> Optional[Dict]:
325
+ with self.conn() as c:
326
+ r = c.execute("SELECT * FROM farts WHERE id=?", (fid,)).fetchone()
327
+ return dict(r) if r else None
328
+
329
+ def leaderboard(self) -> List[Dict]:
330
+ with self.conn() as c:
331
+ return [dict(r) for r in c.execute("SELECT fingerprint, fartscore, ts, note_count FROM farts ORDER BY fartscore DESC LIMIT 20").fetchall()]
332
+
333
+
334
+ # ── Solana Fingerprint + Receipt Ledger ──
335
+ class Ledger:
336
+ @staticmethod
337
+ def fingerprint(audio_bytes: bytes) -> Tuple[str, int]:
338
+ h = hashlib.sha256(audio_bytes).digest()
339
+ score = int(hashlib.sha256(h).hexdigest(), 16) % (Cfg.MAX_FSCORE + 1)
340
+ return "Fart" + b58(h)[:38], score
341
+
342
+ @staticmethod
343
+ def receipt(fid: str, ah: str, fp: str, fscore: int, prev: str) -> str:
344
+ return hashlib.sha256(f"{fid}:{ah}:{fp}:{fscore}:{prev}".encode()).hexdigest()
345
+
346
+
347
+ # ── Pipeline ──
348
+ @dataclass
349
+ class PipelineResult:
350
+ fart_id: str
351
+ fingerprint: str
352
+ fartscore: int
353
+ duration_sec: float
354
+ note_count: int
355
+ midi_path: Optional[str]
356
+ notes: List[Tuple[float, float, int, int]]
357
+ model_outputs: List[Dict]
358
+ llm_outputs: List[Dict]
359
+ receipt: str
360
+ prev_receipt: str
361
+
362
+
363
+ class Pipeline:
364
+ def __init__(self):
365
+ self.yin = YIN()
366
+ self.reg = Registry()
367
+ self.db = DB()
368
+ self.led = Ledger()
369
+ self.out_dir = Path("output")
370
+ self.out_dir.mkdir(exist_ok=True)
371
+
372
+ def run(self, wav_path: Path) -> PipelineResult:
373
+ start = time.time()
374
+ y, sr = WavUtil.read(wav_path)
375
+ dur = len(y) / sr
376
+ audio_bytes = open(wav_path, "rb").read()
377
+ ah = hashlib.sha256(audio_bytes).hexdigest()
378
+ fp, fscore = self.led.fingerprint(audio_bytes)
379
+ prev = self.db.latest_receipt() or ""
380
+
381
+ pitches = self.yin.detect(y)
382
+ notes = self._segment(pitches)
383
+ midi_file = self.out_dir / f"{fp[:12]}_{int(time.time())}.mid"
384
+ SMF.from_notes(notes, instrument=58, path=midi_file)
385
+
386
+ ml_out, llm_out = [], []
387
+ for m in self.reg.ML:
388
+ t0 = time.time()
389
+ res = self.reg.infer(m, audio_path=wav_path)
390
+ lat = (time.time() - t0) * 1000
391
+ ml_out.append({"model": m["id"], "role": m["role"], "task": m["task"], "result": res, "latency_ms": round(lat, 2)})
392
+ self.db.insert_analysis(fp[:16], m, res, lat)
393
+
394
+ prompts = [
395
+ "Write a haiku about this fart.",
396
+ "Name this fart like a startup.",
397
+ "Write a fake Nature abstract about this acoustic emission.",
398
+ "Roast this fart mercilessly.",
399
+ "Translate this fart into Shakespearean English.",
400
+ "Write Solana memecoin tokenomics for this fart.",
401
+ ]
402
+ for m, pr in zip(self.reg.LLM, prompts):
403
+ t0 = time.time()
404
+ res = self.reg.infer(m, audio_path=wav_path, prompt=pr)
405
+ lat = (time.time() - t0) * 1000
406
+ llm_out.append({"model": m["id"], "role": m["role"], "prompt": pr, "result": res, "latency_ms": round(lat, 2)})
407
+ self.db.insert_analysis(fp[:16], m, res, lat)
408
+
409
+ receipt = self.led.receipt(fp[:16], ah, fp, fscore, prev)
410
+ report = {"fingerprint": fp, "fartscore": fscore, "duration": dur, "note_count": len(notes), "models": ml_out + llm_out}
411
+ self.db.insert_fart(fp[:16], ah, fp, fscore, str(midi_file), len(notes), dur, report, prev, receipt)
412
+
413
+ logger.info(f"Processed {fp[:16]} score={fscore}/100 notes={len(notes)} receipt={receipt[:16]}...")
414
+ return PipelineResult(
415
+ fart_id=fp[:16], fingerprint=fp, fartscore=fscore, duration_sec=dur,
416
+ note_count=len(notes), midi_path=str(midi_file) if midi_file.exists() else None,
417
+ notes=notes, model_outputs=ml_out, llm_outputs=llm_out, receipt=receipt, prev_receipt=prev,
418
+ )
419
+
420
+ def _segment(self, pitches: List[Tuple[float, Optional[float]]]) -> List[Tuple[float, float, int, int]]:
421
+ notes = []
422
+ active = False
423
+ nstart = 0.0
424
+ cur = None
425
+ for t, p in pitches:
426
+ if p and 40 <= p <= 2000:
427
+ mn = max(0, min(127, int(69 + 12 * np.log2(p / 440))))
428
+ vel = min(127, max(30, int(70 + np.random.randn() * 20)))
429
+ if not active:
430
+ active, nstart, cur = True, t, mn
431
+ elif abs(mn - cur) > 2:
432
+ if t - nstart >= 0.05:
433
+ notes.append((nstart, t - nstart, cur, vel))
434
+ nstart, cur = t, mn
435
+ else:
436
+ if active and t - nstart >= 0.05:
437
+ notes.append((nstart, t - nstart, cur, vel))
438
+ active = False
439
+ if active and len(pitches) > 0 and pitches[-1][0] - nstart >= 0.05:
440
+ notes.append((nstart, pitches[-1][0] - nstart, cur, vel))
441
+ return notes
442
+
443
+
444
+ # ── FastAPI App ──
445
+ app = FastAPI(title="AFIP", version=Cfg.VERSION, description="Acoustic Flatulence Intelligence Platform — 36-model ensemble + YIN→MIDI + on-chain receipts")
446
+ app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"])
447
+ pipe = Pipeline()
448
+
449
+ # Static mount for output MIDI files + frontend
450
+ Path("output").mkdir(exist_ok=True)
451
+ if Path("static").exists():
452
+ app.mount("/static", StaticFiles(directory="static"), name="static")
453
+ app.mount("/output", StaticFiles(directory="output"), name="output")
454
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
455
 
456
  @app.get("/")
457
  def root():
458
+ if Path("static/index.html").exists():
459
+ return FileResponse("static/index.html")
460
+ return {"name": Cfg.APP_NAME, "version": Cfg.VERSION, "models": len(Registry.ALL)}
461
+
462
+
463
+ @app.get("/health")
464
+ def health():
465
+ return {"status": "ok", "models_loaded": len(Registry.ALL), "db": str(Cfg.DB_PATH)}
466
+
467
 
468
+ @app.get("/registry")
469
+ def registry():
470
+ return {"ml_models": Registry.ML, "llm_models": Registry.LLM, "total": len(Registry.ALL)}
471
+
472
+
473
+ @app.get("/history")
474
+ def history(limit: int = 50):
475
+ return pipe.db.list_farts(limit=limit)
476
+
477
+
478
+ @app.get("/leaderboard")
479
+ def leaderboard():
480
+ return pipe.db.leaderboard()
481
+
482
+
483
+ @app.get("/fart/{fart_id}")
484
+ def get_fart(fart_id: str):
485
+ r = pipe.db.get_fart(fart_id)
486
+ if not r:
487
+ raise HTTPException(status_code=404, detail="Fart not found")
488
+ return r
489
+
490
+
491
+ @app.post("/analyze")
492
  async def analyze(file: UploadFile = File(...)):
493
+ suffix = Path(file.filename).suffix.lower()
494
+ if suffix not in {".wav", ".webm", ".ogg", ".mp3"}:
495
+ raise HTTPException(status_code=400, detail="Only WAV/WebM/OGG audio files accepted")
496
+
497
+ tmp = Path(tempfile.gettempdir()) / f"afip_{int(time.time()*1000)}.wav"
498
  try:
499
+ content = await file.read()
500
+ open(tmp, "wb").write(content)
501
+ # If not WAV, attempt naive resample by re-reading (best-effort)
502
+ y, sr = WavUtil.read(tmp)
503
+ if sr != Cfg.SR:
504
+ import librosa
505
+
506
+ y = librosa.resample(y, orig_sr=sr, target_sr=Cfg.SR)
507
+ WavUtil.write(tmp, y, Cfg.SR)
508
+ result = pipe.run(tmp)
509
+ return {
510
+ "fart_id": result.fart_id,
511
+ "fingerprint": result.fingerprint,
512
+ "fartscore": result.fartscore,
513
+ "duration_sec": result.duration_sec,
514
+ "note_count": result.note_count,
515
+ "midi_url": f"/output/{Path(result.midi_path).name}" if result.midi_path else None,
516
+ "receipt": result.receipt,
517
+ "prev_receipt": result.prev_receipt,
518
+ "model_outputs": result.model_outputs,
519
+ "llm_outputs": result.llm_outputs,
520
+ "notes": [{"start": s, "duration": d, "midi": n, "velocity": v} for s, d, n, v in result.notes],
521
+ }
522
+ except Exception as e:
523
+ logger.error(f"Analysis failed: {e}", exc_info=True)
524
+ raise HTTPException(status_code=500, detail=str(e))
525
  finally:
526
+ if tmp.exists():
527
+ tmp.unlink()
528
 
 
 
 
 
 
 
529
 
530
+ @app.get("/download/{fname}")
531
+ def download(fname: str):
532
+ p = Path("output") / fname
533
+ if not p.exists():
534
+ raise HTTPException(status_code=404)
535
+ return FileResponse(p, media_type="audio/midi", filename=fname)
 
 
 
 
 
 
 
536
 
 
 
 
 
 
 
 
 
537
 
538
  if __name__ == "__main__":
539
  import uvicorn
540
+
541
+ logger.info(f"AFIP {Cfg.VERSION} starting on port {Cfg.PORT}")
542
+ uvicorn.run(app, host="0.0.0.0", port=Cfg.PORT)