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
63
kisum/README.md
Normal file
63
kisum/README.md
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
# KISUM
|
||||
|
||||
*musik al revés*: le pasas un tema de audio (o el audio de un vídeo) y
|
||||
KISUM lo escucha, saca el tempo y los golpes, y te lo devuelve como
|
||||
**código Strudel** listo para pegar en tu Strudel local (`localhost:3009`)
|
||||
y seguir tocándolo en vivo.
|
||||
|
||||
```
|
||||
tema.mp3 / clip.mp4 ──> análisis (tempo, onsets por bandas, bajo) ──> patrón ──> Strudel
|
||||
```
|
||||
|
||||
**Acepta cualquier archivo con audio**: `mp3`, `wav`, `flac`, `m4a`, `ogg`...
|
||||
y también vídeo (`mp4`, `mkv`, `webm`, `mov`...). Los formatos de audio se
|
||||
leen directos; para vídeo u otros contenedores extrae la pista con `ffmpeg`
|
||||
de forma automática (necesitas tener `ffmpeg` instalado).
|
||||
|
||||
## Instalación
|
||||
|
||||
```bash
|
||||
cd ~/COFRE/CODERS/MUSIKA/AUDIO/PROYECTOS/KISUM
|
||||
python3 -m venv .venv
|
||||
.venv/bin/pip install -r requirements.txt
|
||||
```
|
||||
|
||||
## Uso
|
||||
|
||||
```bash
|
||||
.venv/bin/python3 kisum.py tema.mp3 # imprime el código Strudel
|
||||
.venv/bin/python3 kisum.py clip.mp4 # vale también un vídeo
|
||||
.venv/bin/python3 kisum.py tema.mp3 --bars 8 # analiza 8 compases
|
||||
.venv/bin/python3 kisum.py tema.mp3 --bass # añade línea de bajo (experimental)
|
||||
.venv/bin/python3 kisum.py tema.mp3 -o tema.strudel
|
||||
```
|
||||
|
||||
## Cómo funciona (v0)
|
||||
|
||||
1. **Tempo y rejilla** — `librosa.beat.beat_track` saca el BPM y los tiempos
|
||||
de negra; cada negra se parte en 4 (semicorcheas) → rejilla de 16 pasos
|
||||
por compás (4/4).
|
||||
2. **Golpes por bandas** — detección de onsets en tres bandas:
|
||||
graves (20–150 Hz → `bd`), medios (150–2500 Hz → `sd`),
|
||||
agudos (5–11 kHz → `hh`).
|
||||
3. **Cuantización** — cada golpe cae en el paso más cercano de la rejilla.
|
||||
4. **Salida** — un `stack()` de patrones Strudel, un compás entre `[ ]` y
|
||||
los compases alternando con `< >`.
|
||||
5. **`--bass`** — croma por pulso sobre el audio grave → `note("...")`.
|
||||
Es aproximado: úsalo como punto de partida, no como transcripción.
|
||||
|
||||
## Limitaciones conocidas de la v0
|
||||
|
||||
- Asume 4/4 y tempo estable (techno/house van perfectos; jazz no).
|
||||
- No separa instrumentos: un golpe grave "es" un bombo aunque fuera otra
|
||||
cosa. La separación por stems (demucs) está en la hoja de ruta.
|
||||
- El BPM puede salir al doble o a la mitad; corrígelo en el `setcps`.
|
||||
|
||||
## Hoja de ruta
|
||||
|
||||
- [x] v0: batería por bandas + tempo → Strudel (este directorio)
|
||||
- [x] Soporte universal de archivos: audio + vídeo (extracción con ffmpeg)
|
||||
- [ ] Separación por stems (demucs) y transcripción por stem
|
||||
- [ ] Melodía/acordes (basic-pitch) → `note()` con más de una voz
|
||||
- [ ] Elegir samples de Strudel según el timbre detectado
|
||||
- [ ] Modo directo: escuchar por el micro/monitor y transcribir en vivo
|
||||
27
kisum/install.sh
Executable file
27
kisum/install.sh
Executable file
|
|
@ -0,0 +1,27 @@
|
|||
#!/usr/bin/env bash
|
||||
# Instala el entorno de KISUM (Python + librosa) en ./.venv de esta carpeta.
|
||||
# Portable: no depende de rutas absolutas, funciona en cualquier Linux.
|
||||
set -euo pipefail
|
||||
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
cd "$DIR"
|
||||
|
||||
echo "==> KISUM: comprobando requisitos"
|
||||
command -v python3 >/dev/null 2>&1 || {
|
||||
echo "ERROR: falta python3. Instálalo con el gestor de tu distro." >&2
|
||||
exit 1
|
||||
}
|
||||
if ! command -v ffmpeg >/dev/null 2>&1; then
|
||||
echo "AVISO: ffmpeg no está instalado. KISUM lo necesita para leer vídeo"
|
||||
echo " (mp4/mkv...) y algunos audios. Instálalo:"
|
||||
echo " Debian/Ubuntu: sudo apt install ffmpeg"
|
||||
echo " Fedora: sudo dnf install ffmpeg"
|
||||
echo " Arch: sudo pacman -S ffmpeg"
|
||||
fi
|
||||
|
||||
echo "==> KISUM: creando entorno virtual (.venv)"
|
||||
python3 -m venv .venv
|
||||
./.venv/bin/pip install --upgrade pip -q
|
||||
echo "==> KISUM: instalando dependencias (librosa, soundfile, numpy)…"
|
||||
./.venv/bin/pip install -r requirements.txt
|
||||
|
||||
echo "==> KISUM listo. Prueba: ./kisum tu_cancion.mp3"
|
||||
27
kisum/kisum
Executable file
27
kisum/kisum
Executable file
|
|
@ -0,0 +1,27 @@
|
|||
#!/usr/bin/env bash
|
||||
# Lanzador de KISUM: ejecuta kisum.py con su propio venv, desde donde sea.
|
||||
#
|
||||
# ./kisum archivo.mp4
|
||||
# ./kisum archivo.mp4 --bars 8 --bass
|
||||
# ./kisum ~/Downloads/cancion.wav -o cancion.strudel
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
# Directorio real de este script (resolviendo symlinks, por si esta en el PATH)
|
||||
SOURCE="${BASH_SOURCE[0]}"
|
||||
while [ -h "$SOURCE" ]; do
|
||||
DIR="$(cd -P "$(dirname "$SOURCE")" && pwd)"
|
||||
SOURCE="$(readlink "$SOURCE")"
|
||||
[[ $SOURCE != /* ]] && SOURCE="$DIR/$SOURCE"
|
||||
done
|
||||
DIR="$(cd -P "$(dirname "$SOURCE")" && pwd)"
|
||||
|
||||
PY="$DIR/.venv/bin/python3"
|
||||
if [ ! -x "$PY" ]; then
|
||||
echo "KISUM: no encuentro el entorno (.venv)." >&2
|
||||
echo "Montalo con:" >&2
|
||||
echo " cd \"$DIR\" && python3 -m venv .venv && .venv/bin/pip install -r requirements.txt" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
exec "$PY" "$DIR/kisum.py" "$@"
|
||||
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()
|
||||
120
kisum/kisum_server.py
Normal file
120
kisum/kisum_server.py
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Mini-servidor local de KISUM para integrarlo en Strudel.
|
||||
|
||||
Expone un endpoint HTTP que recibe un archivo de audio/video, lo analiza con
|
||||
kisum.analyze() y devuelve el codigo Strudel en JSON. Pensado para correr en
|
||||
local (127.0.0.1) junto a Strudel; lo arranca strudel-launcher.sh.
|
||||
|
||||
python3 kisum_server.py # escucha en 127.0.0.1:3010
|
||||
KISUM_PORT=7331 python3 kisum_server.py
|
||||
|
||||
Endpoints:
|
||||
GET /health -> {"ok": true}
|
||||
POST /analyze?bars=4&bass=1 (cuerpo = bytes del archivo,
|
||||
cabecera X-Filename: nombre.mp4)
|
||||
-> {"code": "<codigo strudel>", "filename": ...}
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from urllib.parse import urlparse, parse_qs
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
if HERE not in sys.path:
|
||||
sys.path.insert(0, HERE)
|
||||
|
||||
# Import pesado (librosa) al arrancar: asi la primera peticion ya es rapida.
|
||||
import kisum # noqa: E402
|
||||
|
||||
PORT = int(os.environ.get("KISUM_PORT", "3010"))
|
||||
|
||||
|
||||
def _cors(handler):
|
||||
handler.send_header("Access-Control-Allow-Origin", "*")
|
||||
handler.send_header("Access-Control-Allow-Methods", "POST, GET, OPTIONS")
|
||||
handler.send_header("Access-Control-Allow-Headers", "Content-Type, X-Filename")
|
||||
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def log_message(self, *a):
|
||||
pass # silencio: el log lo lleva el launcher
|
||||
|
||||
def _json(self, code, obj):
|
||||
body = json.dumps(obj, ensure_ascii=False).encode("utf-8")
|
||||
self.send_response(code)
|
||||
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
_cors(self)
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def do_OPTIONS(self):
|
||||
self.send_response(204)
|
||||
_cors(self)
|
||||
self.end_headers()
|
||||
|
||||
def do_GET(self):
|
||||
path = urlparse(self.path).path
|
||||
if path in ("/health", "/"):
|
||||
self._json(200, {"ok": True, "service": "kisum"})
|
||||
else:
|
||||
self._json(404, {"error": "not found"})
|
||||
|
||||
def do_POST(self):
|
||||
parsed = urlparse(self.path)
|
||||
if parsed.path != "/analyze":
|
||||
self._json(404, {"error": "not found"})
|
||||
return
|
||||
|
||||
qs = parse_qs(parsed.query)
|
||||
try:
|
||||
bars = max(1, int(qs.get("bars", ["4"])[0]))
|
||||
except ValueError:
|
||||
bars = 4
|
||||
want_bass = qs.get("bass", ["0"])[0] in ("1", "true", "yes", "on")
|
||||
|
||||
filename = self.headers.get("X-Filename", "audio")
|
||||
ext = os.path.splitext(filename)[1] or ".bin"
|
||||
|
||||
length = int(self.headers.get("Content-Length", "0") or 0)
|
||||
if length <= 0:
|
||||
self._json(400, {"error": "archivo vacio"})
|
||||
return
|
||||
data = self.rfile.read(length)
|
||||
|
||||
tmp = tempfile.NamedTemporaryFile(suffix=ext, delete=False)
|
||||
tmp.write(data)
|
||||
tmp.close()
|
||||
try:
|
||||
code = kisum.analyze(tmp.name, bars, want_bass)
|
||||
# el comentario de cabecera lleva la ruta temporal: la cambiamos
|
||||
# por el nombre real del archivo que subio el usuario
|
||||
code = code.replace(tmp.name, filename)
|
||||
self._json(200, {"code": code, "filename": filename})
|
||||
except SystemExit as e:
|
||||
# kisum.analyze() hace sys.exit(msg) cuando no encuentra pulso, etc.
|
||||
msg = str(e.code) if e.code else "no se pudo analizar el audio"
|
||||
self._json(422, {"error": msg})
|
||||
except Exception as e: # noqa: BLE001
|
||||
self._json(500, {"error": f"{type(e).__name__}: {e}"})
|
||||
finally:
|
||||
try:
|
||||
os.remove(tmp.name)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def main():
|
||||
srv = ThreadingHTTPServer(("127.0.0.1", PORT), Handler)
|
||||
print(f"KISUM server escuchando en http://127.0.0.1:{PORT}", flush=True)
|
||||
try:
|
||||
srv.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
3
kisum/requirements.txt
Normal file
3
kisum/requirements.txt
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
librosa>=0.10
|
||||
soundfile
|
||||
numpy
|
||||
Loading…
Add table
Add a link
Reference in a new issue