Integra KISUM (audio→Strudel) + pestaña Visor + accesos en la cabecera
- Pestaña KISUM: sube audio/vídeo y lo convierte en código Strudel vía un mini-servidor Python local (kisum/, puerto 3010) que arranca el lanzador. - Pestaña Visor: piano-roll (pentagrama) + osciloscopio + espectro en vivo de lo que suena, sin escribir .pianoroll()/.scope(). - Botones en la cabecera: projects, KISUM, visor, ajustes. - KISUM empaquetado en kisum/ (código, sin venv) + install.sh de un comando y lanzador portable (sin rutas absolutas). - .gitignore excluye venv/cachés/logs de KISUM. Docs en README. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
970b89b08c
commit
7ace286d8a
13 changed files with 1052 additions and 13 deletions
221
kisum/kisum.py
Normal file
221
kisum/kisum.py
Normal file
|
|
@ -0,0 +1,221 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
KISUM - musik al reves.
|
||||
|
||||
Escucha un tema de audio (o el audio de un video) y lo devuelve como codigo
|
||||
Strudel: tempo, bateria por bandas (bombo/caja/hats) y, opcionalmente, una
|
||||
linea de bajo aproximada.
|
||||
|
||||
Acepta cualquier archivo con audio: mp3, wav, flac, ogg, m4a... y tambien
|
||||
video (mp4, mkv, webm, mov...). Si el formato no es audio puro, extrae la
|
||||
pista con ffmpeg automaticamente.
|
||||
|
||||
Uso:
|
||||
python3 kisum.py tema.mp3 [--bars 4] [--bass] [-o salida.strudel]
|
||||
python3 kisum.py clip.mp4 # tambien vale un video
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import warnings
|
||||
|
||||
import numpy as np
|
||||
|
||||
try:
|
||||
import librosa
|
||||
import soundfile as sf
|
||||
except ImportError:
|
||||
sys.exit("Faltan dependencias. Instalalas:\n"
|
||||
" python3 -m venv .venv && "
|
||||
".venv/bin/pip install -r requirements.txt")
|
||||
|
||||
STEPS_PER_BEAT = 4 # semicorcheas
|
||||
BEATS_PER_BAR = 4 # asumimos 4/4
|
||||
STEPS_PER_BAR = STEPS_PER_BEAT * BEATS_PER_BAR
|
||||
|
||||
# Bandas de frecuencia -> sample de Strudel
|
||||
BANDS = [
|
||||
("bd", 20, 150), # bombo
|
||||
("sd", 150, 2500), # caja / palmas
|
||||
("hh", 5000, 11000), # hats / brillos
|
||||
]
|
||||
|
||||
NOTE_NAMES = ["c", "cs", "d", "eb", "e", "f",
|
||||
"fs", "g", "gs", "a", "bb", "b"]
|
||||
|
||||
|
||||
def load_audio(path):
|
||||
"""Carga el audio de `path` como (senal mono float32, sr).
|
||||
|
||||
Primero intenta leerlo directamente con libsndfile (wav, flac, ogg,
|
||||
mp3...). Si no es un formato de audio que entienda (un video mp4/mkv/
|
||||
webm/mov, u otro contenedor), extrae la pista de audio con ffmpeg a un
|
||||
wav temporal. Asi KISUM acepta cualquier archivo con audio sin depender
|
||||
del backend audioread (deprecado en librosa)."""
|
||||
if not os.path.exists(path):
|
||||
sys.exit(f"No existe el archivo: {path}")
|
||||
|
||||
# 1) intento directo: wav/flac/ogg/mp3 via libsndfile
|
||||
try:
|
||||
y, sr = sf.read(path, dtype="float32", always_2d=True)
|
||||
return y.mean(axis=1), sr # mezcla a mono
|
||||
except Exception:
|
||||
pass # no es audio legible -> ffmpeg
|
||||
|
||||
# 2) fallback: extraer audio con ffmpeg (videos y formatos raros)
|
||||
if not shutil.which("ffmpeg"):
|
||||
sys.exit(f"No puedo leer '{path}' directamente y no encuentro ffmpeg.\n"
|
||||
"Instala ffmpeg (sudo apt install ffmpeg) o convierte el "
|
||||
"archivo a wav/mp3.")
|
||||
|
||||
tmp_wav = tempfile.NamedTemporaryFile(suffix=".wav", delete=False).name
|
||||
try:
|
||||
res = subprocess.run(
|
||||
["ffmpeg", "-y", "-loglevel", "error", "-i", path,
|
||||
"-vn", "-ac", "1", "-ar", "44100", tmp_wav],
|
||||
capture_output=True, text=True)
|
||||
if res.returncode != 0 or not os.path.getsize(tmp_wav):
|
||||
sys.exit(f"ffmpeg no pudo extraer audio de '{path}'.\n"
|
||||
f"{res.stderr.strip()}")
|
||||
y, sr = sf.read(tmp_wav, dtype="float32", always_2d=True)
|
||||
return y.mean(axis=1), sr
|
||||
finally:
|
||||
if os.path.exists(tmp_wav):
|
||||
os.remove(tmp_wav)
|
||||
|
||||
|
||||
def band_onsets(y, sr, fmin, fmax):
|
||||
"""Tiempos (s) de los golpes dentro de una banda de frecuencias."""
|
||||
with warnings.catch_warnings():
|
||||
# las bandas son estrechas: sobran filtros mel y librosa avisa.
|
||||
# Es inofensivo (esos canales quedan vacios), lo silenciamos.
|
||||
warnings.filterwarnings("ignore", message="Empty filters detected")
|
||||
S = librosa.feature.melspectrogram(
|
||||
y=y, sr=sr, n_mels=32, fmin=fmin, fmax=fmax)
|
||||
env = librosa.onset.onset_strength(S=librosa.power_to_db(S), sr=sr)
|
||||
frames = librosa.onset.onset_detect(
|
||||
onset_envelope=env, sr=sr, backtrack=False)
|
||||
return librosa.frames_to_time(frames, sr=sr)
|
||||
|
||||
|
||||
def make_grid(beat_times, bars):
|
||||
"""Rejilla de semicorcheas: subdivide cada pulso en 4. Devuelve los
|
||||
tiempos (s) de cada paso, recortados a `bars` compases completos."""
|
||||
steps = []
|
||||
for i in range(len(beat_times) - 1):
|
||||
t0, t1 = beat_times[i], beat_times[i + 1]
|
||||
for k in range(STEPS_PER_BEAT):
|
||||
steps.append(t0 + (t1 - t0) * k / STEPS_PER_BEAT)
|
||||
return steps[: bars * STEPS_PER_BAR]
|
||||
|
||||
|
||||
def quantize(onsets, grid):
|
||||
"""Marca en que paso de la rejilla cae cada golpe (al mas cercano,
|
||||
con tolerancia de medio paso)."""
|
||||
hits = [False] * len(grid)
|
||||
if not len(grid):
|
||||
return hits
|
||||
step = np.median(np.diff(grid)) if len(grid) > 1 else 0.1
|
||||
for t in onsets:
|
||||
i = int(np.argmin(np.abs(np.asarray(grid) - t)))
|
||||
if abs(grid[i] - t) <= step / 2:
|
||||
hits[i] = True
|
||||
return hits
|
||||
|
||||
|
||||
def pattern_string(hits, token):
|
||||
"""Convierte la lista de pasos en mini-notacion Strudel: un compas por
|
||||
[ ] y, si hay varios, alternando con < >."""
|
||||
bars = []
|
||||
for b in range(0, len(hits), STEPS_PER_BAR):
|
||||
bar = hits[b:b + STEPS_PER_BAR]
|
||||
if len(bar) < STEPS_PER_BAR:
|
||||
break
|
||||
bars.append("[" + " ".join(token if h else "~" for h in bar) + "]")
|
||||
if not bars:
|
||||
return None
|
||||
if len(bars) == 1:
|
||||
return bars[0][1:-1] # sin corchetes si es uno
|
||||
if all(b == bars[0] for b in bars):
|
||||
return bars[0][1:-1] # todos iguales: uno basta
|
||||
return "<" + " ".join(bars) + ">"
|
||||
|
||||
|
||||
def bass_line(y, sr, beat_times, bars):
|
||||
"""Nota dominante por pulso sobre el audio grave (experimental)."""
|
||||
chroma = librosa.feature.chroma_cqt(y=y, sr=sr, fmin=32.7) # desde C1
|
||||
beats = librosa.time_to_frames(beat_times, sr=sr)
|
||||
notes = []
|
||||
for i in range(min(len(beats) - 1, bars * BEATS_PER_BAR)):
|
||||
seg = chroma[:, beats[i]:beats[i + 1]]
|
||||
if seg.size == 0 or seg.max() < 0.5:
|
||||
notes.append("~")
|
||||
else:
|
||||
notes.append(NOTE_NAMES[int(seg.mean(axis=1).argmax())] + "2")
|
||||
if not notes or all(n == "~" for n in notes):
|
||||
return None
|
||||
return " ".join(notes)
|
||||
|
||||
|
||||
def analyze(path, bars, want_bass):
|
||||
y, sr = load_audio(path)
|
||||
tempo, beat_times = librosa.beat.beat_track(y=y, sr=sr, units="time")
|
||||
tempo = float(np.atleast_1d(tempo)[0])
|
||||
if len(beat_times) < BEATS_PER_BAR + 1:
|
||||
sys.exit("No encuentro un pulso claro en el audio. "
|
||||
"Prueba con un fragmento con percusion.")
|
||||
grid = make_grid(beat_times, bars)
|
||||
|
||||
lines = []
|
||||
for token, fmin, fmax in BANDS:
|
||||
hits = quantize(band_onsets(y, sr, fmin, fmax), grid)
|
||||
pat = pattern_string(hits, token)
|
||||
if pat:
|
||||
gain = '.gain(0.7)' if token == "hh" else ""
|
||||
lines.append(f' s("{pat}"){gain},')
|
||||
|
||||
if want_bass:
|
||||
bl = bass_line(y, sr, beat_times, bars)
|
||||
if bl:
|
||||
lines.append(f' note("{bl}").s("sawtooth").lpf(400)'
|
||||
f'.gain(0.6), // bajo experimental')
|
||||
|
||||
bpm = round(tempo, 1)
|
||||
code = [
|
||||
f"// KISUM v0 :: {path}",
|
||||
f"// bpm detectado ~ {bpm} (si suena a mitad/doble, corrige aqui)",
|
||||
f"setcps({bpm}/60/4)",
|
||||
"stack(",
|
||||
*lines,
|
||||
")",
|
||||
]
|
||||
return "\n".join(code) + "\n"
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(
|
||||
description="KISUM: de un tema de audio a codigo Strudel.")
|
||||
ap.add_argument("audio",
|
||||
help="archivo con audio: mp3, wav, flac, m4a, ogg... "
|
||||
"o video (mp4, mkv, webm, mov...)")
|
||||
ap.add_argument("--bars", type=int, default=4,
|
||||
help="compases a transcribir (defecto: 4)")
|
||||
ap.add_argument("--bass", action="store_true",
|
||||
help="anade linea de bajo aproximada")
|
||||
ap.add_argument("-o", "--out", help="guardar tambien en un archivo")
|
||||
args = ap.parse_args()
|
||||
|
||||
code = analyze(args.audio, max(1, args.bars), args.bass)
|
||||
print(code)
|
||||
if args.out:
|
||||
with open(args.out, "w", encoding="utf-8") as fh:
|
||||
fh.write(code)
|
||||
print(f"// guardado en {args.out}", file=sys.stderr)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Loading…
Add table
Add a link
Reference in a new issue