Mejoras hacklab: i18n, pestaña Projects, transporte con seek, guía y temas

- i18n ligero de la interfaz del REPL (castellano, inglés, francés, alemán,
  italiano, portugués...) con selector en el header
- Pestaña Projects: gestor local de proyectos con carpetas, buscador y
  librería de tracks de fábrica (bases techno/acid originales)
- Barra de transporte bajo el editor: play/pausa/anterior/siguiente, tiempo
  y barra de progreso arrastrable; seek real vía Cyclist.setCycle()
- Pestaña Guía: chuleta de síntesis y sonidos para el directo
- Packs de temas del editor: neon-madrid + hacklab-pack (5 neón + 5 estándar)
- pnpm-workspace: allowBuilds para instalación limpia en Linux
This commit is contained in:
hacklab 2026-07-17 01:47:12 +02:00
parent c150752372
commit 7b9f1155b4
13 changed files with 2731 additions and 55 deletions

View file

@ -1,4 +1,27 @@
import strudelTheme, { settings as strudelThemeSettings } from './themes/strudel-theme.mjs';
import neonMadrid, { settings as neonMadridSettings } from './themes/neon-madrid.mjs';
import {
neonCian,
neonCianSettings,
neonMagenta,
neonMagentaSettings,
neonVerde,
neonVerdeSettings,
neonVioleta,
neonVioletaSettings,
neonRave,
neonRaveSettings,
madridClaro,
madridClaroSettings,
madridOscuro,
madridOscuroSettings,
papelSepia,
papelSepiaSettings,
medianoche,
medianocheSettings,
grafito,
grafitoSettings,
} from './themes/hacklab-pack.mjs';
import bluescreen, { settings as bluescreenSettings } from './themes/bluescreen.mjs';
import blackscreen, { settings as blackscreenSettings } from './themes/blackscreen.mjs';
import whitescreen, { settings as whitescreenSettings } from './themes/whitescreen.mjs';
@ -43,6 +66,17 @@ import noctisLilac, { settings as noctisLilacSettings } from './themes/noctisLil
import { setTheme } from '@strudel/draw';
export const themes = {
strudelTheme,
neonMadrid,
neonCian,
neonMagenta,
neonVerde,
neonVioleta,
neonRave,
madridClaro,
madridOscuro,
papelSepia,
medianoche,
grafito,
algoboy,
archBtw,
androidstudio,
@ -85,6 +119,17 @@ export const themes = {
export const settings = {
strudelTheme: strudelThemeSettings,
neonMadrid: neonMadridSettings,
neonCian: neonCianSettings,
neonMagenta: neonMagentaSettings,
neonVerde: neonVerdeSettings,
neonVioleta: neonVioletaSettings,
neonRave: neonRaveSettings,
madridClaro: madridClaroSettings,
madridOscuro: madridOscuroSettings,
papelSepia: papelSepiaSettings,
medianoche: medianocheSettings,
grafito: grafitoSettings,
bluescreen: bluescreenSettings,
bluescreenlight: bluescreenlightsettings,
blackscreen: blackscreenSettings,

View file

@ -0,0 +1,240 @@
import { tags as t } from '@lezer/highlight';
import { createTheme } from './theme-helper.mjs';
// ═══════════════════════════════════════════════════════════════════
// PACK HACKLAB · 10 temas: 5 NEÓN sobre negro + 5 estándar
// Mismo formato que neon-madrid.mjs: default = createTheme, settings aparte.
// ═══════════════════════════════════════════════════════════════════
// ── helper: genera un tema neón sobre negro a partir de 3 acentos ──
const neon = ({ base, acento, extra, seleccion }) => ({
settings: {
background: '#050507',
lineBackground: '#05050799',
foreground: base,
muted: `${base}40`,
caret: acento,
selection: seleccion,
selectionMatch: `${acento}33`,
lineHighlight: `${base}0d`,
gutterBackground: 'transparent',
gutterForeground: `${base}66`,
},
theme: createTheme({
theme: 'dark',
settings: {
background: '#050507',
lineBackground: '#05050799',
foreground: base,
caret: acento,
selection: seleccion,
selectionMatch: `${acento}33`,
lineHighlight: `${base}0d`,
gutterBackground: 'transparent',
gutterForeground: `${base}66`,
},
styles: [
{ tag: [t.atom, t.bool, t.number], color: extra },
{ tag: t.labelName, color: acento },
{ tag: t.keyword, color: acento },
{ tag: t.operator, color: acento },
{ tag: [t.variableName, t.propertyName, t.tagName, t.typeName], color: base },
{ tag: t.definition(t.variableName), color: extra },
{ tag: [t.string, t.special(t.string)], color: extra },
{ tag: t.comment, color: `${base}77` },
{ tag: [t.bracket, t.unit, t.punctuation], color: acento },
{ tag: [t.meta, t.attributeName], color: extra },
{ tag: t.className, color: extra },
{ tag: t.invalid, color: '#ff2222' },
],
}),
});
// ── 5 NEÓN ──────────────────────────────────────────────────────────
// cian eléctrico + magenta
const neonCian_ = neon({ base: '#00e5ff', acento: '#ff00d4', extra: '#7dff00', seleccion: 'rgba(255,0,212,.25)' });
export const neonCianSettings = neonCian_.settings;
export const neonCian = neonCian_.theme;
// magenta/rosa + cian
const neonMagenta_ = neon({ base: '#ff2ec4', acento: '#00e5ff', extra: '#ffe600', seleccion: 'rgba(0,229,255,.25)' });
export const neonMagentaSettings = neonMagenta_.settings;
export const neonMagenta = neonMagenta_.theme;
// verde matrix + lima
const neonVerde_ = neon({ base: '#39ff14', acento: '#00ffc8', extra: '#eaff00', seleccion: 'rgba(0,255,200,.25)' });
export const neonVerdeSettings = neonVerde_.settings;
export const neonVerde = neonVerde_.theme;
// violeta + rosa
const neonVioleta_ = neon({ base: '#b26bff', acento: '#ff4fd8', extra: '#00e5ff', seleccion: 'rgba(255,79,216,.25)' });
export const neonVioletaSettings = neonVioleta_.settings;
export const neonVioleta = neonVioleta_.theme;
// rave multicolor: base ámbar, acentos cian/verde (fiesta total)
const neonRave_ = neon({ base: '#ffb300', acento: '#00e5ff', extra: '#ff2ec4', seleccion: 'rgba(57,255,20,.25)' });
export const neonRaveSettings = neonRave_.settings;
export const neonRave = neonRave_.theme;
// ── 5 ESTÁNDAR ──────────────────────────────────────────────────────
// claro limpio (blanco, azules/teal — para trabajar de día)
export const madridClaroSettings = {
background: '#ffffff',
lineBackground: '#ffffff99',
foreground: '#1e293b',
muted: '#1e293b26',
caret: '#0369a1',
selection: 'rgba(2,132,199,.18)',
selectionMatch: 'rgba(2,132,199,.12)',
lineHighlight: 'rgba(2,132,199,.05)',
gutterBackground: 'transparent',
gutterForeground: '#1e293b55',
};
export const madridClaro = createTheme({
theme: 'light',
settings: madridClaroSettings,
styles: [
{ tag: [t.atom, t.bool, t.number], color: '#0d9488' },
{ tag: t.labelName, color: '#c026d3' },
{ tag: t.keyword, color: '#7c3aed' },
{ tag: t.operator, color: '#0369a1' },
{ tag: [t.variableName, t.propertyName, t.tagName, t.typeName], color: '#0f172a' },
{ tag: t.definition(t.variableName), color: '#b45309' },
{ tag: [t.string, t.special(t.string)], color: '#15803d' },
{ tag: t.comment, color: '#94a3b8' },
{ tag: [t.bracket, t.unit, t.punctuation], color: '#0369a1' },
{ tag: [t.meta, t.attributeName], color: '#0d9488' },
{ tag: t.className, color: '#b45309' },
{ tag: t.invalid, color: '#dc2626' },
],
});
// oscuro neutro (gris carbón, acentos suaves — sobrio)
export const madridOscuroSettings = {
background: '#1c1e24',
lineBackground: '#1c1e2499',
foreground: '#d7dae0',
muted: '#d7dae026',
caret: '#61afef',
selection: 'rgba(97,175,239,.22)',
selectionMatch: 'rgba(97,175,239,.15)',
lineHighlight: 'rgba(215,218,224,.05)',
gutterBackground: 'transparent',
gutterForeground: '#d7dae055',
};
export const madridOscuro = createTheme({
theme: 'dark',
settings: madridOscuroSettings,
styles: [
{ tag: [t.atom, t.bool, t.number], color: '#d19a66' },
{ tag: t.labelName, color: '#c678dd' },
{ tag: t.keyword, color: '#c678dd' },
{ tag: t.operator, color: '#56b6c2' },
{ tag: [t.variableName, t.propertyName, t.tagName, t.typeName], color: '#e5c07b' },
{ tag: t.definition(t.variableName), color: '#61afef' },
{ tag: [t.string, t.special(t.string)], color: '#98c379' },
{ tag: t.comment, color: '#7f848e' },
{ tag: [t.bracket, t.unit, t.punctuation], color: '#56b6c2' },
{ tag: [t.meta, t.attributeName], color: '#d19a66' },
{ tag: t.className, color: '#e5c07b' },
{ tag: t.invalid, color: '#f14c4c' },
],
});
// papel sepia (cálido tipo solarized claro — descansa la vista)
export const papelSepiaSettings = {
background: '#f5edda',
lineBackground: '#f5edda99',
foreground: '#59493a',
muted: '#59493a26',
caret: '#b58900',
selection: 'rgba(181,137,0,.22)',
selectionMatch: 'rgba(181,137,0,.14)',
lineHighlight: 'rgba(89,73,58,.06)',
gutterBackground: 'transparent',
gutterForeground: '#59493a55',
};
export const papelSepia = createTheme({
theme: 'light',
settings: papelSepiaSettings,
styles: [
{ tag: [t.atom, t.bool, t.number], color: '#b58900' },
{ tag: t.labelName, color: '#d33682' },
{ tag: t.keyword, color: '#859900' },
{ tag: t.operator, color: '#cb4b16' },
{ tag: [t.variableName, t.propertyName, t.tagName, t.typeName], color: '#268bd2' },
{ tag: t.definition(t.variableName), color: '#6c71c4' },
{ tag: [t.string, t.special(t.string)], color: '#2aa198' },
{ tag: t.comment, color: '#9d8f77' },
{ tag: [t.bracket, t.unit, t.punctuation], color: '#cb4b16' },
{ tag: [t.meta, t.attributeName], color: '#b58900' },
{ tag: t.className, color: '#6c71c4' },
{ tag: t.invalid, color: '#dc322f' },
],
});
// medianoche (azul profundo — clásico nocturno)
export const medianocheSettings = {
background: '#0f172a',
lineBackground: '#0f172a99',
foreground: '#cbd5e1',
muted: '#cbd5e126',
caret: '#38bdf8',
selection: 'rgba(56,189,248,.22)',
selectionMatch: 'rgba(56,189,248,.14)',
lineHighlight: 'rgba(203,213,225,.05)',
gutterBackground: 'transparent',
gutterForeground: '#cbd5e155',
};
export const medianoche = createTheme({
theme: 'dark',
settings: medianocheSettings,
styles: [
{ tag: [t.atom, t.bool, t.number], color: '#fbbf24' },
{ tag: t.labelName, color: '#f472b6' },
{ tag: t.keyword, color: '#818cf8' },
{ tag: t.operator, color: '#38bdf8' },
{ tag: [t.variableName, t.propertyName, t.tagName, t.typeName], color: '#7dd3fc' },
{ tag: t.definition(t.variableName), color: '#c4b5fd' },
{ tag: [t.string, t.special(t.string)], color: '#6ee7b7' },
{ tag: t.comment, color: '#64748b' },
{ tag: [t.bracket, t.unit, t.punctuation], color: '#38bdf8' },
{ tag: [t.meta, t.attributeName], color: '#fbbf24' },
{ tag: t.className, color: '#c4b5fd' },
{ tag: t.invalid, color: '#f87171' },
],
});
// grafito (monocromo con un solo acento ámbar — minimalista)
export const grafitoSettings = {
background: '#232323',
lineBackground: '#23232399',
foreground: '#c9c9c9',
muted: '#c9c9c926',
caret: '#ffb300',
selection: 'rgba(255,179,0,.22)',
selectionMatch: 'rgba(255,179,0,.14)',
lineHighlight: 'rgba(201,201,201,.05)',
gutterBackground: 'transparent',
gutterForeground: '#c9c9c955',
};
export const grafito = createTheme({
theme: 'dark',
settings: grafitoSettings,
styles: [
{ tag: [t.atom, t.bool, t.number], color: '#ffb300' },
{ tag: t.labelName, color: '#ffb300' },
{ tag: t.keyword, color: '#e0e0e0' },
{ tag: t.operator, color: '#9e9e9e' },
{ tag: [t.variableName, t.propertyName, t.tagName, t.typeName], color: '#dedede' },
{ tag: t.definition(t.variableName), color: '#ffffff' },
{ tag: [t.string, t.special(t.string)], color: '#ffd54f' },
{ tag: t.comment, color: '#757575' },
{ tag: [t.bracket, t.unit, t.punctuation], color: '#9e9e9e' },
{ tag: [t.meta, t.attributeName], color: '#ffb300' },
{ tag: t.className, color: '#ffffff' },
{ tag: t.invalid, color: '#ff5252' },
],
});

View file

@ -0,0 +1,45 @@
import { tags as t } from '@lezer/highlight';
import { createTheme } from './theme-helper.mjs';
// Tema NEÓN: naranja neón dominante (texto de UI y código base),
// con acentos VERDE NEÓN (números, átomos) y CIAN (operadores, corchetes, puntuación).
export const settings = {
background: '#0a0a0c',
lineBackground: '#0a0a0c99',
foreground: '#ff7a18', // naranja neón — todo el texto de la interfaz y el código base
muted: '#ff7a1840', // bordes/separadores en naranja tenue
caret: '#00e5ff', // cursor cian
selection: 'rgba(57,255,20,0.25)', // selección verde neón
selectionMatch: 'rgba(0,229,255,0.22)',
lineHighlight: 'rgba(255,122,24,0.07)',
gutterBackground: 'transparent',
gutterForeground: '#ff7a1866',
};
export default createTheme({
theme: 'dark',
settings,
styles: [
{ tag: [t.atom, t.bool, t.special(t.variableName)], color: '#39ff14' }, // verde neón
{ tag: t.labelName, color: '#00e5ff' }, // $: en cian
{ tag: t.keyword, color: '#ff5e00' }, // const, => naranja fuerte
{ tag: t.operator, color: '#00e5ff' }, // + - * / cian
{ tag: t.special(t.variableName), color: '#ffb877' },
{ tag: t.typeName, color: '#ff9a3c' },
{ tag: t.atom, color: '#39ff14' },
{ tag: t.number, color: '#39ff14' }, // números verde neón
{ tag: t.definition(t.variableName), color: '#ffb877' }, // nombres de const
{ tag: t.string, color: '#ffd59e' }, // "bd*4" "RolandTR909" ámbar cálido (legible)
{ tag: t.special(t.string), color: '#ffd59e' },
{ tag: t.comment, color: '#9a7a55' }, // comentarios apagados
{ tag: t.variableName, color: '#ff9a3c' }, // s, note, stack... naranja
{ tag: t.tagName, color: '#ff9a3c' },
{ tag: t.bracket, color: '#00e5ff' }, // ( ) [ ] cian
{ tag: t.meta, color: '#39ff14' },
{ tag: t.attributeName, color: '#39ff14' },
{ tag: t.propertyName, color: '#ff9a3c' }, // .lpf .gain .bank naranja
{ tag: t.className, color: '#ffb877' },
{ tag: t.invalid, color: '#ff2222' },
{ tag: [t.unit, t.punctuation], color: '#00e5ff' }, // puntuación cian
],
});

View file

@ -133,6 +133,14 @@ export class Cyclist {
this.cps = cps;
this.num_ticks_since_cps_change = 0;
}
// salta a un ciclo concreto (seek). El siguiente tick re-ancla la fase del
// reloj igual que hace un cambio de cps: num_cycles_at_cps_change tomará
// lastEnd (el ciclo pedido) y seconds_at_cps_change la fase actual.
setCycle(cycle) {
this.num_ticks_since_cps_change = 0;
this.lastBegin = cycle;
this.lastEnd = cycle;
}
log(begin, end, haps) {
const onsets = haps.filter((h) => h.hasOnset());
console.log(`${begin.toFixed(4)} - ${end.toFixed(4)} ${Array(onsets.length).fill('I').join('')}`);

View file

@ -19,3 +19,11 @@ catalog:
"@codemirror/view": "^6.36.2"
"@codemirror/autocomplete": "^6.18.4"
"@codemirror/search": "^6.5.8"
allowBuilds:
'@serialport/bindings-cpp': false
esbuild: true
nx: true
sharp: true
tree-sitter: false
tree-sitter-haskell: false

725
website/src/i18n.mjs Normal file
View file

@ -0,0 +1,725 @@
// i18n ligero para la interfaz del REPL.
// El idioma vive en settings ('language') y se cambia desde el selector del header.
// t(key) devuelve la cadena en el idioma activo (con fallback inglés → clave).
import { settingsMap, useSettings } from './settings.mjs';
export const LANGS = {
es: 'Español',
en: 'English',
fr: 'Français',
de: 'Deutsch',
it: 'Italiano',
pt: 'Português',
nl: 'Nederlands',
pl: 'Polski',
cs: 'Čeština',
sk: 'Slovenčina',
ro: 'Română',
hu: 'Magyar',
el: 'Ελληνικά',
sv: 'Svenska',
da: 'Dansk',
no: 'Norsk',
fi: 'Suomi',
uk: 'Українська',
};
// Diccionario: clave → { idioma: texto }. Orden de idiomas igual que LANGS.
const S = {
play: {
es: 'play',
en: 'play',
fr: 'lecture',
de: 'Play',
it: 'play',
pt: 'tocar',
nl: 'afspelen',
pl: 'graj',
cs: 'přehrát',
sk: 'prehrať',
ro: 'redare',
hu: 'lejátszás',
el: 'αναπαραγωγή',
sv: 'spela',
da: 'afspil',
no: 'spill',
fi: 'toista',
uk: 'грати',
},
stop: {
es: 'stop',
en: 'stop',
fr: 'stop',
de: 'Stopp',
it: 'stop',
pt: 'parar',
nl: 'stop',
pl: 'stop',
cs: 'stop',
sk: 'stop',
ro: 'stop',
hu: 'állj',
el: 'στοπ',
sv: 'stopp',
da: 'stop',
no: 'stopp',
fi: 'pysäytä',
uk: 'стоп',
},
pause: {
es: 'pausa',
en: 'pause',
fr: 'pause',
de: 'Pause',
it: 'pausa',
pt: 'pausa',
nl: 'pauze',
pl: 'pauza',
cs: 'pauza',
sk: 'pauza',
ro: 'pauză',
hu: 'szünet',
el: 'παύση',
sv: 'paus',
da: 'pause',
no: 'pause',
fi: 'tauko',
uk: 'пауза',
},
previous: {
es: 'anterior',
en: 'previous',
fr: 'précédent',
de: 'Zurück',
it: 'precedente',
pt: 'anterior',
nl: 'vorige',
pl: 'poprzedni',
cs: 'předchozí',
sk: 'predchádzajúci',
ro: 'anterior',
hu: 'előző',
el: 'προηγούμενο',
sv: 'föregående',
da: 'forrige',
no: 'forrige',
fi: 'edellinen',
uk: 'попередній',
},
next: {
es: 'siguiente',
en: 'next',
fr: 'suivant',
de: 'Weiter',
it: 'successivo',
pt: 'seguinte',
nl: 'volgende',
pl: 'następny',
cs: 'další',
sk: 'ďalší',
ro: 'următor',
hu: 'következő',
el: 'επόμενο',
sv: 'nästa',
da: 'næste',
no: 'neste',
fi: 'seuraava',
uk: 'наступний',
},
update: {
es: 'actualizar',
en: 'update',
fr: 'mettre à jour',
de: 'Aktualisieren',
it: 'aggiorna',
pt: 'atualizar',
nl: 'bijwerken',
pl: 'aktualizuj',
cs: 'aktualizovat',
sk: 'aktualizovať',
ro: 'actualizează',
hu: 'frissítés',
el: 'ενημέρωση',
sv: 'uppdatera',
da: 'opdater',
no: 'oppdater',
fi: 'päivitä',
uk: 'оновити',
},
share: {
es: 'compartir',
en: 'share',
fr: 'partager',
de: 'Teilen',
it: 'condividi',
pt: 'partilhar',
nl: 'delen',
pl: 'udostępnij',
cs: 'sdílet',
sk: 'zdieľať',
ro: 'partajează',
hu: 'megosztás',
el: 'κοινοποίηση',
sv: 'dela',
da: 'del',
no: 'del',
fi: 'jaa',
uk: 'поділитися',
},
learn: {
es: 'aprender',
en: 'learn',
fr: 'apprendre',
de: 'Lernen',
it: 'impara',
pt: 'aprender',
nl: 'leren',
pl: 'nauka',
cs: 'učit se',
sk: 'učiť sa',
ro: 'învață',
hu: 'tanulás',
el: 'μάθηση',
sv: 'lär dig',
da: 'lær',
no: 'lær',
fi: 'opi',
uk: 'вчитися',
},
// pestañas del panel
tabProjects: {
es: 'projects',
en: 'projects',
fr: 'projets',
de: 'Projekte',
it: 'progetti',
pt: 'projetos',
nl: 'projecten',
pl: 'projekty',
cs: 'projekty',
sk: 'projekty',
ro: 'proiecte',
hu: 'projektek',
el: 'έργα',
sv: 'projekt',
da: 'projekter',
no: 'prosjekter',
fi: 'projektit',
uk: 'проєкти',
},
tabGuide: {
es: 'guía',
en: 'guide',
fr: 'guide',
de: 'Anleitung',
it: 'guida',
pt: 'guia',
nl: 'gids',
pl: 'przewodnik',
cs: 'průvodce',
sk: 'sprievodca',
ro: 'ghid',
hu: 'útmutató',
el: 'οδηγός',
sv: 'guide',
da: 'guide',
no: 'guide',
fi: 'opas',
uk: 'посібник',
},
tabIntro: {
es: 'intro',
en: 'intro',
fr: 'intro',
de: 'Intro',
it: 'intro',
pt: 'intro',
nl: 'intro',
pl: 'intro',
cs: 'úvod',
sk: 'úvod',
ro: 'intro',
hu: 'intro',
el: 'εισαγωγή',
sv: 'intro',
da: 'intro',
no: 'intro',
fi: 'intro',
uk: 'вступ',
},
tabPatterns: {
es: 'patterns',
en: 'patterns',
fr: 'motifs',
de: 'Patterns',
it: 'pattern',
pt: 'padrões',
nl: 'patronen',
pl: 'wzorce',
cs: 'vzory',
sk: 'vzory',
ro: 'tipare',
hu: 'minták',
el: 'μοτίβα',
sv: 'mönster',
da: 'mønstre',
no: 'mønstre',
fi: 'kuviot',
uk: 'патерни',
},
tabSounds: {
es: 'sonidos',
en: 'sounds',
fr: 'sons',
de: 'Klänge',
it: 'suoni',
pt: 'sons',
nl: 'geluiden',
pl: 'dźwięki',
cs: 'zvuky',
sk: 'zvuky',
ro: 'sunete',
hu: 'hangok',
el: 'ήχοι',
sv: 'ljud',
da: 'lyde',
no: 'lyder',
fi: 'äänet',
uk: 'звуки',
},
tabReference: {
es: 'referencia',
en: 'reference',
fr: 'référence',
de: 'Referenz',
it: 'riferimento',
pt: 'referência',
nl: 'referentie',
pl: 'odniesienie',
cs: 'reference',
sk: 'referencia',
ro: 'referință',
hu: 'referencia',
el: 'αναφορά',
sv: 'referens',
da: 'reference',
no: 'referanse',
fi: 'viite',
uk: 'довідник',
},
tabExport: {
es: 'exportar',
en: 'export',
fr: 'exporter',
de: 'Export',
it: 'esporta',
pt: 'exportar',
nl: 'exporteren',
pl: 'eksport',
cs: 'export',
sk: 'export',
ro: 'export',
hu: 'exportálás',
el: 'εξαγωγή',
sv: 'exportera',
da: 'eksport',
no: 'eksport',
fi: 'vie',
uk: 'експорт',
},
tabConsole: {
es: 'consola',
en: 'console',
fr: 'console',
de: 'Konsole',
it: 'console',
pt: 'consola',
nl: 'console',
pl: 'konsola',
cs: 'konzole',
sk: 'konzola',
ro: 'consolă',
hu: 'konzol',
el: 'κονσόλα',
sv: 'konsol',
da: 'konsol',
no: 'konsoll',
fi: 'konsoli',
uk: 'консоль',
},
tabSettings: {
es: 'ajustes',
en: 'settings',
fr: 'réglages',
de: 'Einstellungen',
it: 'impostazioni',
pt: 'definições',
nl: 'instellingen',
pl: 'ustawienia',
cs: 'nastavení',
sk: 'nastavenia',
ro: 'setări',
hu: 'beállítások',
el: 'ρυθμίσεις',
sv: 'inställningar',
da: 'indstillinger',
no: 'innstillinger',
fi: 'asetukset',
uk: 'налаштування',
},
tabFiles: {
es: 'archivos',
en: 'files',
fr: 'fichiers',
de: 'Dateien',
it: 'file',
pt: 'ficheiros',
nl: 'bestanden',
pl: 'pliki',
cs: 'soubory',
sk: 'súbory',
ro: 'fișiere',
hu: 'fájlok',
el: 'αρχεία',
sv: 'filer',
da: 'filer',
no: 'filer',
fi: 'tiedostot',
uk: 'файли',
},
// transporte
seekHint: {
es: 'click o arrastra para saltar a ese punto del loop',
en: 'click or drag to jump to that point of the loop',
fr: 'cliquez ou glissez pour sauter à ce point de la boucle',
de: 'Klicken oder ziehen, um zu diesem Punkt des Loops zu springen',
it: 'clicca o trascina per saltare a quel punto del loop',
pt: 'clica ou arrasta para saltar para esse ponto do loop',
nl: 'klik of sleep om naar dat punt van de loop te springen',
pl: 'kliknij lub przeciągnij, aby skoczyć do tego punktu pętli',
cs: 'kliknutím nebo tažením skočíte na dané místo smyčky',
sk: 'kliknutím alebo ťahaním skočíte na dané miesto slučky',
ro: 'clic sau trage pentru a sări la acel punct al buclei',
hu: 'kattints vagy húzd a hurok adott pontjára ugráshoz',
el: 'κλικ ή σύρσιμο για μετάβαση σε αυτό το σημείο του loop',
sv: 'klicka eller dra för att hoppa till den punkten i loopen',
da: 'klik eller træk for at hoppe til det punkt i loopet',
no: 'klikk eller dra for å hoppe til det punktet i loopen',
fi: 'klikkaa tai vedä hypätäksesi siihen kohtaan luuppia',
uk: 'клацніть або перетягніть, щоб перейти до цієї точки циклу',
},
resizeHint: {
es: 'arrastra para redimensionar',
en: 'drag to resize',
fr: 'glissez pour redimensionner',
de: 'Zum Anpassen ziehen',
it: 'trascina per ridimensionare',
pt: 'arrasta para redimensionar',
nl: 'sleep om te vergroten/verkleinen',
pl: 'przeciągnij, aby zmienić rozmiar',
cs: 'tažením změníte velikost',
sk: 'ťahaním zmeníte veľkosť',
ro: 'trage pentru redimensionare',
hu: 'húzd az átméretezéshez',
el: 'σύρετε για αλλαγή μεγέθους',
sv: 'dra för att ändra storlek',
da: 'træk for at ændre størrelse',
no: 'dra for å endre størrelse',
fi: 'vedä muuttaaksesi kokoa',
uk: 'перетягніть, щоб змінити розмір',
},
cycles: {
es: 'ciclos',
en: 'cycles',
fr: 'cycles',
de: 'Zyklen',
it: 'cicli',
pt: 'ciclos',
nl: 'cycli',
pl: 'cykle',
cs: 'cykly',
sk: 'cykly',
ro: 'cicluri',
hu: 'ciklusok',
el: 'κύκλοι',
sv: 'cykler',
da: 'cyklusser',
no: 'sykluser',
fi: 'syklit',
uk: 'цикли',
},
// projects
save: {
es: 'guardar',
en: 'save',
fr: 'enregistrer',
de: 'Speichern',
it: 'salva',
pt: 'guardar',
nl: 'opslaan',
pl: 'zapisz',
cs: 'uložit',
sk: 'uložiť',
ro: 'salvează',
hu: 'mentés',
el: 'αποθήκευση',
sv: 'spara',
da: 'gem',
no: 'lagre',
fi: 'tallenna',
uk: 'зберегти',
},
projectPlaceholder: {
es: 'nombre del proyecto (o carpeta/nombre)...',
en: 'project name (or folder/name)...',
fr: 'nom du projet (ou dossier/nom)...',
de: 'Projektname (oder Ordner/Name)...',
it: 'nome del progetto (o cartella/nome)...',
pt: 'nome do projeto (ou pasta/nome)...',
nl: 'projectnaam (of map/naam)...',
pl: 'nazwa projektu (lub folder/nazwa)...',
cs: 'název projektu (nebo složka/název)...',
sk: 'názov projektu (alebo priečinok/názov)...',
ro: 'numele proiectului (sau dosar/nume)...',
hu: 'projekt neve (vagy mappa/név)...',
el: 'όνομα έργου (ή φάκελος/όνομα)...',
sv: 'projektnamn (eller mapp/namn)...',
da: 'projektnavn (eller mappe/navn)...',
no: 'prosjektnavn (eller mappe/navn)...',
fi: 'projektin nimi (tai kansio/nimi)...',
uk: 'назва проєкту (або папка/назва)...',
},
copy: {
es: 'copiar',
en: 'copy',
fr: 'copier',
de: 'Kopieren',
it: 'copia',
pt: 'copiar',
nl: 'kopiëren',
pl: 'kopiuj',
cs: 'kopírovat',
sk: 'kopírovať',
ro: 'copiază',
hu: 'másolás',
el: 'αντιγραφή',
sv: 'kopiera',
da: 'kopiér',
no: 'kopier',
fi: 'kopioi',
uk: 'копіювати',
},
copied: {
es: 'copiado',
en: 'copied',
fr: 'copié',
de: 'Kopiert',
it: 'copiato',
pt: 'copiado',
nl: 'gekopieerd',
pl: 'skopiowano',
cs: 'zkopírováno',
sk: 'skopírované',
ro: 'copiat',
hu: 'másolva',
el: 'αντιγράφηκε',
sv: 'kopierad',
da: 'kopieret',
no: 'kopiert',
fi: 'kopioitu',
uk: 'скопійовано',
},
load: {
es: 'cargar',
en: 'load',
fr: 'charger',
de: 'Laden',
it: 'carica',
pt: 'carregar',
nl: 'laden',
pl: 'wczytaj',
cs: 'načíst',
sk: 'načítať',
ro: 'încarcă',
hu: 'betöltés',
el: 'φόρτωση',
sv: 'ladda',
da: 'indlæs',
no: 'last inn',
fi: 'lataa',
uk: 'завантажити',
},
read: {
es: 'leer',
en: 'read',
fr: 'lire',
de: 'Lesen',
it: 'leggi',
pt: 'ler',
nl: 'lezen',
pl: 'czytaj',
cs: 'číst',
sk: 'čítať',
ro: 'citește',
hu: 'olvasás',
el: 'ανάγνωση',
sv: 'läs',
da: 'læs',
no: 'les',
fi: 'lue',
uk: 'читати',
},
myProjects: {
es: 'mis proyectos',
en: 'my projects',
fr: 'mes projets',
de: 'meine Projekte',
it: 'i miei progetti',
pt: 'os meus projetos',
nl: 'mijn projecten',
pl: 'moje projekty',
cs: 'moje projekty',
sk: 'moje projekty',
ro: 'proiectele mele',
hu: 'projektjeim',
el: 'τα έργα μου',
sv: 'mina projekt',
da: 'mine projekter',
no: 'mine prosjekter',
fi: 'omat projektit',
uk: 'мої проєкти',
},
noProjects: {
es: 'Aún no hay proyectos. Escribe un nombre y pulsa guardar.',
en: 'No projects yet. Type a name and press save.',
fr: 'Pas encore de projets. Saisissez un nom et appuyez sur enregistrer.',
de: 'Noch keine Projekte. Namen eingeben und Speichern drücken.',
it: 'Ancora nessun progetto. Scrivi un nome e premi salva.',
pt: 'Ainda não há projetos. Escreve um nome e carrega em guardar.',
nl: 'Nog geen projecten. Typ een naam en druk op opslaan.',
pl: 'Brak projektów. Wpisz nazwę i naciśnij zapisz.',
cs: 'Zatím žádné projekty. Zadejte název a stiskněte uložit.',
sk: 'Zatiaľ žiadne projekty. Zadajte názov a stlačte uložiť.',
ro: 'Încă nu există proiecte. Scrie un nume și apasă salvează.',
hu: 'Még nincsenek projektek. Írj be egy nevet és nyomd meg a mentést.',
el: 'Δεν υπάρχουν έργα ακόμα. Γράψτε ένα όνομα και πατήστε αποθήκευση.',
sv: 'Inga projekt än. Skriv ett namn och tryck på spara.',
da: 'Ingen projekter endnu. Skriv et navn og tryk på gem.',
no: 'Ingen prosjekter ennå. Skriv et navn og trykk lagre.',
fi: 'Ei vielä projekteja. Kirjoita nimi ja paina tallenna.',
uk: 'Проєктів ще немає. Введіть назву й натисніть зберегти.',
},
previewTitle: {
es: 'vista previa',
en: 'preview',
fr: 'aperçu',
de: 'Vorschau',
it: 'anteprima',
pt: 'pré-visualização',
nl: 'voorbeeld',
pl: 'podgląd',
cs: 'náhled',
sk: 'náhľad',
ro: 'previzualizare',
hu: 'előnézet',
el: 'προεπισκόπηση',
sv: 'förhandsvisning',
da: 'forhåndsvisning',
no: 'forhåndsvisning',
fi: 'esikatselu',
uk: 'попередній перегляд',
},
loadInEditor: {
es: 'cargar en editor',
en: 'load in editor',
fr: "charger dans l'éditeur",
de: 'In Editor laden',
it: "carica nell'editor",
pt: 'carregar no editor',
nl: 'laden in editor',
pl: 'wczytaj do edytora',
cs: 'načíst do editoru',
sk: 'načítať do editora',
ro: 'încarcă în editor',
hu: 'betöltés a szerkesztőbe',
el: 'φόρτωση στον επεξεργαστή',
sv: 'ladda i editorn',
da: 'indlæs i editor',
no: 'last inn i editor',
fi: 'lataa editoriin',
uk: 'завантажити в редактор',
},
previewHelp: {
es: 'Haz click en el nombre de un tema para leer su código aquí. No se ejecuta ni toca lo que está sonando — solo «cargar» reemplaza el editor.',
en: 'Click a track name to read its code here. It does not run nor touch what is playing — only "load" replaces the editor.',
fr: "Cliquez sur le nom d'un morceau pour lire son code ici. Rien ne s'exécute — seul « charger » remplace l'éditeur.",
de: 'Klicke auf einen Tracknamen, um seinen Code hier zu lesen. Nichts wird ausgeführt — nur „Laden" ersetzt den Editor.',
it: "Clicca sul nome di un brano per leggerne il codice qui. Non viene eseguito — solo «carica» sostituisce l'editor.",
pt: 'Clica no nome de uma faixa para ler o seu código aqui. Nada é executado — só «carregar» substitui o editor.',
nl: 'Klik op een tracknaam om de code hier te lezen. Er wordt niets uitgevoerd — alleen "laden" vervangt de editor.',
pl: 'Kliknij nazwę utworu, aby przeczytać jego kod. Nic się nie uruchamia — tylko „wczytaj" zastępuje edytor.',
cs: 'Klikněte na název skladby a přečtěte si zde její kód. Nic se nespustí — jen „načíst" nahradí editor.',
sk: 'Kliknite na názov skladby a prečítajte si tu jej kód. Nič sa nespustí — len „načítať" nahradí editor.',
ro: 'Dă clic pe numele unei piese pentru a-i citi codul aici. Nimic nu rulează — doar „încarcă" înlocuiește editorul.',
hu: 'Kattints egy szám nevére, hogy itt elolvasd a kódját. Semmi sem fut le — csak a „betöltés" cseréli le a szerkesztőt.',
el: 'Κάντε κλικ στο όνομα ενός κομματιού για να δείτε τον κώδικά του εδώ. Δεν εκτελείται τίποτα — μόνο η «φόρτωση» αντικαθιστά τον επεξεργαστή.',
sv: 'Klicka på ett spårnamn för att läsa dess kod här. Inget körs — bara "ladda" ersätter editorn.',
da: 'Klik på et nummernavn for at læse dets kode her. Intet kører — kun "indlæs" erstatter editoren.',
no: 'Klikk på et spornavn for å lese koden her. Ingenting kjøres — bare "last inn" erstatter editoren.',
fi: 'Klikkaa kappaleen nimeä lukeaksesi sen koodin tässä. Mitään ei suoriteta — vain "lataa" korvaa editorin.',
uk: 'Клацніть назву треку, щоб прочитати його код тут. Нічого не виконується — лише «завантажити» замінює редактор.',
},
theme: {
es: 'tema',
en: 'theme',
fr: 'thème',
de: 'Thema',
it: 'tema',
pt: 'tema',
nl: 'thema',
pl: 'motyw',
cs: 'motiv',
sk: 'motív',
ro: 'temă',
hu: 'téma',
el: 'θέμα',
sv: 'tema',
da: 'tema',
no: 'tema',
fi: 'teema',
uk: 'тема',
},
language: {
es: 'idioma',
en: 'language',
fr: 'langue',
de: 'Sprache',
it: 'lingua',
pt: 'idioma',
nl: 'taal',
pl: 'język',
cs: 'jazyk',
sk: 'jazyk',
ro: 'limbă',
hu: 'nyelv',
el: 'γλώσσα',
sv: 'språk',
da: 'sprog',
no: 'språk',
fi: 'kieli',
uk: 'мова',
},
};
export const getLang = () => settingsMap.get().language || 'es';
export const translate = (key, lang) => S[key]?.[lang] ?? S[key]?.en ?? key;
// hook de React: re-renderiza al cambiar el idioma en settings
export function useT() {
const { language } = useSettings();
const lang = language || 'es';
return (key) => translate(key, lang);
}
export const setLanguage = (lang) => settingsMap.setKey('language', lang);

View file

@ -1,6 +1,7 @@
import { Code } from '@src/repl/components/Code';
import Loader from '@src/repl/components/Loader';
import { BottomPanel, MainPanel, RightPanel } from '@src/repl/components/panel/Panel';
import { TransportBar } from '@src/repl/components/TransportBar';
import UserFacingErrorMessage from '@src/repl/components/UserFacingErrorMessage';
import { useSettings } from '@src/settings.mjs';
@ -22,7 +23,11 @@ export default function ReplEditor(Props) {
{/* <MainPanel context={context} isEmbedded={isEmbedded} className="hidden sm:block" /> */}
<MainPanel context={context} isEmbedded={isEmbedded} />
<div className="flex overflow-hidden h-full">
{/* editor (izquierda) + barra de transporte FIJA debajo del código */}
<div className="flex flex-col grow min-w-0 overflow-hidden">
<Code containerRef={containerRef} editorRef={editorRef} init={init} />
{!isZen && <TransportBar context={context} />}
</div>
{!isZen && panelPosition === 'right' && <RightPanel context={context} />}
</div>
</div>

View file

@ -0,0 +1,219 @@
import { useStore } from '@nanostores/react';
import { useEffect, useRef, useState } from 'react';
import cx from '@src/cx.mjs';
import { useT } from '@src/i18n.mjs';
import { $projects, $playing, parseMeta, fmt } from './panel/ProjectsTab.jsx';
// Barra de transporte estilo Spotify, FIJA debajo del editor de código:
// · fila superior: controles CENTRADOS (anterior / play-pausa / siguiente)
// · fila inferior: tiempo transcurrido · barra ARRASTRABLE (seek) · duración
// La barra permite saltar a un segundo concreto del loop (click o arrastre;
// el salto se aplica al soltar). Pausar recuerda la posición y reanudar
// continúa desde ahí (scheduler.setCycle). Arrastrar con el tema parado fija
// el punto desde el que arrancará el play.
export function TransportBar({ context }) {
const projects = useStore($projects);
const playingName = useStore($playing);
const [, tick] = useState(0);
const t = useT();
const [dragFrac, setDragFrac] = useState(null); // fracción bajo el dedo mientras se arrastra
const pausedAtRef = useRef(null); // ciclo (dentro del loop) donde se pausó
const barRef = useRef(null);
// refresco de la barra (~8 fps)
useEffect(() => {
const id = setInterval(() => tick((t) => (t + 1) % 1e9), 120);
return () => clearInterval(id);
}, []);
// al cambiar de tema se descarta la posición de pausa (era de otro loop)
useEffect(() => {
pausedAtRef.current = null;
}, [playingName]);
const editor = () => context.editorRef.current;
const ed = editor();
const sch = ed?.repl?.scheduler;
const started = !!sch?.started;
// longitud del loop = del código que está REALMENTE en el editor (lo que suena)
const meta = parseMeta(ed?.code ?? '');
const loopCycles = Math.max(meta.loopCycles || 1, 1e-6);
const cps = (started && sch?.cps ? sch.cps : meta.cps) || 0.5;
// posición dentro del loop: sonando scheduler; pausado posición recordada;
// arrastrando manda el dedo (solo visual hasta soltar)
const pausedAt = pausedAtRef.current;
const nowCycle = started ? (sch?.now?.() ?? 0) : (pausedAt ?? 0);
const posCycle = ((nowCycle % loopCycles) + loopCycles) % loopCycles;
const frac = dragFrac ?? posCycle / loopCycles;
const shownCycle = dragFrac != null ? dragFrac * loopCycles : posCycle;
const totalSec = loopCycles / cps;
const elapsedSec = frac * totalSec;
const pct = `${(Math.min(Math.max(frac, 0), 1) * 100).toFixed(1)}%`;
const displayed = projects.slice().reverse();
const curIdx = displayed.findIndex((x) => x.name === playingName);
// SEEK: saltar a un punto concreto del loop
const seekTo = (f) => {
const e = editor();
if (!e) return;
const target = Math.min(Math.max(f, 0), 0.999) * loopCycles;
const s = e.repl?.scheduler;
if (s?.started) {
// mantenemos la iteración actual del arrange y movemos solo la fase
const base = Math.floor((s.now?.() ?? 0) / loopCycles) * loopCycles;
s.setCycle?.(base + target);
} else {
// parado/pausado: recordar el punto; play arrancará desde ahí
pausedAtRef.current = target;
}
};
const togglePlay = async () => {
const e = editor();
if (!e) return;
const s = e.repl?.scheduler;
if (s?.started) {
// PAUSA: recuerda dónde íbamos (dentro del loop) y para el audio
const now = s.now?.() ?? 0;
pausedAtRef.current = ((now % loopCycles) + loopCycles) % loopCycles;
e.stop();
} else {
// PLAY: si hay posición recordada (pausa o seek en parado), reanuda ahí
const resume = pausedAtRef.current;
pausedAtRef.current = null;
await e.evaluate();
if (resume != null) e.repl?.scheduler?.setCycle?.(resume);
}
};
const skip = (dir) => {
const e = editor();
if (!e || !displayed.length) return;
pausedAtRef.current = null; // cambiar de tema descarta la posición pausada
const base = curIdx < 0 ? (dir > 0 ? -1 : 0) : curIdx;
const idx = (base + dir + displayed.length) % displayed.length;
const p = displayed[idx];
e.setCode(p.code);
$playing.set(p.name);
e.evaluate();
};
// scrubbing con puntero (click o arrastre; el salto se aplica al soltar)
const fracFromEvent = (ev) => {
const el = barRef.current;
if (!el) return 0;
const r = el.getBoundingClientRect();
return Math.min(Math.max((ev.clientX - r.left) / r.width, 0), 1);
};
const onPointerDown = (ev) => {
ev.preventDefault();
ev.currentTarget.setPointerCapture?.(ev.pointerId);
setDragFrac(fracFromEvent(ev));
};
const onPointerMove = (ev) => {
if (dragFrac == null) return;
setDragFrac(fracFromEvent(ev));
};
const onPointerUp = (ev) => {
if (dragFrac == null) return;
const f = fracFromEvent(ev);
setDragFrac(null);
seekTo(f);
};
// 5 marcas de ciclo debajo de la barra (0 total)
const marks = [0, 1, 2, 3, 4].map((i) => Math.round((loopCycles * i) / 4));
const circle =
'rounded-full border-2 border-muted flex items-center justify-center text-foreground hover:bg-cyan-400/10 hover:border-cyan-400/70 transition-colors shrink-0';
return (
<div className="shrink-0 border-t border-muted bg-lineHighlight px-4 pt-2 pb-1 flex flex-col gap-1 select-none">
{/* fila 1: controles CENTRADOS (estilo Spotify) */}
<div className="flex items-center justify-center gap-5">
<button onClick={() => skip(-1)} title={t('previous')} className={cx(circle, 'w-10 h-10 text-lg')}>
</button>
<button
onClick={togglePlay}
title={started ? t('pause') : t('play')}
className={cx(circle, 'w-12 h-12 text-xl', started && 'border-cyan-400 text-cyan-300 bg-cyan-400/10')}
>
{started ? '⏸' : '▶'}
</button>
<button onClick={() => skip(1)} title={t('next')} className={cx(circle, 'w-10 h-10 text-lg')}>
</button>
</div>
{/* fila 2: transcurrido · BARRA ARRASTRABLE · duración */}
<div className="flex items-center gap-3">
<div className="text-xs font-bold tabular-nums text-cyan-300 w-12 text-right shrink-0">{fmt(elapsedSec)}</div>
<div className="flex-1 min-w-0">
<div className="relative">
{/* número flotante = ciclo actual (sigue al dedo al arrastrar) */}
<div
className="absolute -top-3.5 -translate-x-1/2 text-[11px] font-bold text-cyan-300 tabular-nums pointer-events-none whitespace-nowrap"
style={{ left: pct }}
>
{Math.floor(shownCycle)}
</div>
{/* pista clicable / arrastrable */}
<div
ref={barRef}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
className="group h-5 flex items-center cursor-pointer touch-none"
title={t('seekHint')}
>
<div className="relative h-1.5 group-hover:h-2.5 w-full rounded-full bg-muted/50 transition-all">
<div
className={cx(
'absolute left-0 top-0 h-full rounded-full',
dragFrac != null ? 'bg-cyan-300' : 'bg-cyan-400',
)}
style={{ width: pct }}
/>
{/* tirador (visible al pasar el ratón o arrastrar) */}
<div
className={cx(
'absolute top-1/2 -translate-y-1/2 -translate-x-1/2 w-3 h-3 rounded-full bg-foreground shadow transition-opacity',
dragFrac != null ? 'opacity-100' : 'opacity-0 group-hover:opacity-100',
)}
style={{ left: pct }}
/>
</div>
</div>
{/* números de ciclo a lo largo de la barra */}
<div className="relative h-3.5 text-[10px] opacity-60 tabular-nums pointer-events-none">
{marks.map((label, i) => (
<span
key={i}
className={cx(
'absolute top-0',
i === 0 && 'left-0',
i === 4 && 'right-0',
i > 0 && i < 4 && '-translate-x-1/2',
)}
style={i > 0 && i < 4 ? { left: `${i * 25}%` } : undefined}
>
{label}
</span>
))}
</div>
</div>
</div>
<div className="text-right shrink-0 leading-tight w-14">
<div className="text-xs font-bold tabular-nums">{fmt(totalSec)}</div>
<div className="text-[9px] opacity-60 tabular-nums whitespace-nowrap">
{Math.floor(shownCycle)}/{Math.round(loopCycles)} {t('cycles')}
</div>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,131 @@
import cx from '@src/cx.mjs';
import { useSettings } from '@src/settings.mjs';
// Documentación rápida de sonidos para tener a mano mientras se pincha.
const SYNTHS = [
['sawtooth', 'la onda del ácido — rica en armónicos, brilla al filtrarla'],
['square / pulse', 'hueca y nasal — leads, bleeps, acid alternativo'],
['triangle', 'suave, casi seno — sub y tonos redondos'],
['sine', 'seno puro — sub-bajo limpio bajo el ácido'],
['supersaw', 'muro de sierras — stabs de rave, hoover, trance'],
['.fm(i).fmh(r)', 'síntesis FM — campanas, metales, bajos tipo DX'],
['white / pink / brown', 'ruido — risers, hats, texturas, sweeps'],
];
const DRUMS = [
['RolandTR909', 'techno/house — bombo potente, hats metálicos. LA caja del techno'],
['RolandTR808', 'electro/hip-hop — sub-bombo largo, cowbell, claps secos'],
['RolandTR707', 'house — limpia y seca, muy digital 80s'],
['RolandTR606', 'acid/electro — pequeña, hats finos (pareja de la TB-303)'],
['RolandTR727', 'percusión latina — congas, bongos, timbales'],
['RolandTR505', 'synthpop — samples cortos y funcionales'],
['LinnDrum', 'pop 80s — samples reales gated (Prince, Depeche Mode)'],
['LinnLM1', 'el primer drum-sampler icónico'],
['OberheimDMX', 'hip-hop old school, electro-funk'],
['EmuSP12 / AkaiMPC60', 'boom-bap — swing y crujido legendarios'],
['CasioRZ1 / CasioSK1', 'lo-fi barato con mucho carácter'],
['KorgKR55 / KorgMinipops', 'preset-box vintage, dub y exotica (Boards of Canada)'],
['SimmonsSDS5', 'toms electrónicos 80s (pium-pium)'],
['YamahaRX5 / RX21', 'FM digital 80s'],
['BossDR110 / DR55', 'analógicas minúsculas, hats brillantes'],
['RhythmAce', 'órgano-ritmo vintage, muy lo-fi'],
];
const INSTR = [
['piano', 'piano acústico sampleado (real, muy bueno)'],
['gm_epiano1 · gm_epiano2', 'Rhodes / e-piano — detroit, deep house'],
['gm_acoustic_piano · gm_bright_acoustic_piano', 'pianos GM'],
['gm_pad_warm · gm_pad_halo · gm_pad_new_age', 'pads atmosféricos que envuelven'],
['gm_choir_aahs · gm_voice_oohs · gm_synth_choir', 'coros y voces etéreas'],
['gm_string_ensemble_1 · gm_synth_strings_1', 'cuerdas'],
['gm_vibraphone · gm_marimba · gm_xylophone · gm_kalimba', 'láminas/mallets cristalinos'],
['gm_church_organ · gm_drawbar_organ', 'órganos'],
['gm_synth_bass_1 · gm_acoustic_bass', 'bajos melódicos'],
['gm_lead_1_square · gm_lead_2_sawtooth', 'leads de synth'],
];
const SYNTAX = [
['.bank("RolandTR909")', 'elige la caja de ritmos (¡71 disponibles!)'],
['s("bd sd hh oh cp rim lt mt ht")', 'sonidos de batería'],
['note("c2 eb2 g2") · n("0 3 5").scale("C:minor")', 'notas / grados de escala'],
['.lpf() .lpq() .lpenv() .lpd()', 'filtro: corte · resonancia · envolvente · decay = EL ÁCIDO'],
['.distort("2:.4") .crush() .shape()', 'suciedad: distorsión (drive:volumen), bitcrush'],
['.room() .delay() .dt() .dfb()', 'espacio: reverb · delay (tiempo, feedback)'],
['.mask("<1 1 0 1>")', 'que algo aparezca/desaparezca por ciclos'],
['.every(8, x=>...) .degradeBy() .ply() .chunk()', 'variación y mutación'],
['sine.range(300,3000).slow(32)', 'movimiento lento y continuo (respira)'],
];
function Table({ rows, headL, headR }) {
return (
<table className="w-full border-collapse mb-4">
<thead>
<tr className="text-left border-b border-foreground/40">
<th className="py-1 pr-3 font-bold align-top whitespace-nowrap">{headL}</th>
<th className="py-1 font-bold">{headR}</th>
</tr>
</thead>
<tbody>
{rows.map(([a, b]) => (
<tr key={a} className="border-b border-muted align-top">
<td className="py-1 pr-3 font-mono text-xs whitespace-nowrap text-blue-400">{a}</td>
<td className="py-1 opacity-90">{b}</td>
</tr>
))}
</tbody>
</table>
);
}
function Section({ title, children }) {
return (
<div className="mb-5">
<h2 className="text-base font-bold mb-2 text-foreground">{title}</h2>
{children}
</div>
);
}
export function GuideTab() {
const { fontFamily } = useSettings();
return (
<div className="w-full h-full overflow-auto px-4 py-3 text-sm text-foreground" style={{ fontFamily }}>
<p className="opacity-80 mb-4">
Guía rápida de sonidos para construir y pinchar. Todo esto se combina con <code>.</code> encadenando:
p.ej. <span className="font-mono text-xs text-blue-400">s("bd*4").bank("RolandTR909").lpf(800)</span>.
</p>
<Section title="🎛️ Fuentes de synth (.s)">
<Table rows={SYNTHS} headL="sonido" headR="carácter / uso" />
</Section>
<Section title="🥁 Cajas de ritmo — .bank(...) · 71 en total">
<p className="opacity-70 text-xs mb-2">
Se usan con los sonidos de batería: <span className="font-mono text-blue-400">s("bd sd hh cp").bank("RolandTR808")</span>.
Las más icónicas:
</p>
<Table rows={DRUMS} headL="bank" headR="carácter / estilo" />
<p className="opacity-60 text-xs">
y ~50 más: Roland (MC202, MC303, R8, TR626), Korg (KPR77, DDM110, M1), Yamaha (RY30, TG33), Emu, Alesis,
Sequential, Simmons, Casio, Moog, Serge Prueba cualquiera.
</p>
</Section>
<Section title="🎹 Instrumentos melódicos — .s(...) o .sound(...)">
<p className="opacity-70 text-xs mb-2">
Con notas: <span className="font-mono text-blue-400">note("c3 eb3 g3").s("gm_epiano2").room(.4)</span>.
Soundfonts General MIDI (128 instrumentos, prefijo <code>gm_</code>) + samples reales:
</p>
<Table rows={INSTR} headL="instrumento" headR="uso" />
</Section>
<Section title="🔧 Chuleta de funciones">
<Table rows={SYNTAX} headL="función" headR="qué hace" />
</Section>
<p className="opacity-60 text-xs">
Más en la pestaña <b>reference</b> (todas las funciones) y en <b>sounds</b> (explorar samples cargados).
</p>
</div>
);
}

View file

@ -1,13 +1,18 @@
import { Bars3Icon, PlayIcon, StopIcon, XMarkIcon } from '@heroicons/react/16/solid';
import { useState } from 'react';
import cx from '@src/cx.mjs';
import { themes } from '@strudel/codemirror';
import { LANGS, useT, setLanguage } from '@src/i18n.mjs';
import { StrudelIcon } from '@src/repl/components/icons/StrudelIcon';
import { useSettings, setIsZen, setIsPanelOpened, setActiveFooter as setTab } from '../../../settings.mjs';
import { useSettings, setIsZen, setIsPanelOpened, setActiveFooter as setTab, settingsMap } from '../../../settings.mjs';
import '../../Repl.css';
import { useLogger } from '../useLogger';
import { ConsoleTab } from './ConsoleTab';
import ExportTab from './ExportTab';
import { FilesTab } from './FilesTab';
import { PatternsTab } from './PatternsTab';
import { ProjectsTab } from './ProjectsTab';
import { GuideTab } from './GuideTab';
import { Reference } from './Reference';
import { SettingsTab } from './SettingsTab';
import { SoundsTab } from './SoundsTab';
@ -67,16 +72,16 @@ export function MainPanel({ context, isEmbedded = false, className }) {
<nav
id="header"
className={cx(
'flex-none text-black z-[100] text-sm select-none min-h-10 max-h-10',
'flex-none text-black z-[100] text-sm select-none min-h-12 max-h-12',
!isZen && !isEmbedded && 'border-b border-muted bg-lineHighlight',
isZen ? 'h-12 w-8 fixed top-0 left-0' : '',
'flex items-center',
'flex items-center relative',
className,
)}
style={{ fontFamily }}
>
<div className={cx('flex w-full justify-between')}>
<div className="px-3 py-1 flex space-x-2 select-none">
<div className={cx('flex w-full justify-between items-center')}>
<div className="px-3 py-1 flex space-x-2 select-none shrink-0">
<h1
onClick={() => {
if (isEmbedded) window.open(window.location.href.replace('embed', ''));
@ -90,21 +95,33 @@ export function MainPanel({ context, isEmbedded = false, className }) {
{!isZen && (
<div className="space-x-2 flex items-baseline">
<span className="hidden sm:block">strudel</span>
<span className="text-sm font-medium hidden sm:block">REPL</span>
{!hot ? (
<span className="text-sm font-medium hidden sm:block">({ver})</span>
<span className="text-sm font-medium hidden lg:block">({ver})</span>
) : (
<a className="hover:opacity-50" href={pr} target="_blank">
<span className="text-sm font-medium hidden sm:block">({ver})</span>
<span className="text-sm font-medium hidden lg:block">({ver})</span>
</a>
)}
</div>
)}
</h1>
</div>
{/* menú principal CENTRADO de verdad (absoluto respecto al header) */}
{!isZen && !isButtonRowHidden && (
<div className="absolute left-1/2 -translate-x-1/2 hidden md:flex">
<MainMenu isEmbedded={isEmbedded} context={context} />
</div>
)}
{!isZen && (
<div className="flex grow justify-end">
{!isButtonRowHidden && <MainMenu isEmbedded={isEmbedded} context={context} />}
<div className="flex items-center gap-1.5 pr-1 shrink-0">
{/* menú inline en pantallas pequeñas (donde no cabe centrado) */}
{!isButtonRowHidden && (
<div className="flex md:hidden">
<MainMenu isEmbedded={isEmbedded} context={context} />
</div>
)}
<ThemeSelector />
<LanguageSelector />
<PanelToggle isEmbedded={isEmbedded} isZen={isZen} />
</div>
)}
@ -113,6 +130,54 @@ export function MainPanel({ context, isEmbedded = false, className }) {
);
}
// selector de TEMA (header, derecha)
function ThemeSelector() {
const { theme } = useSettings();
const t = useT();
return (
<select
title={t('theme')}
value={theme}
onChange={(e) => settingsMap.setKey('theme', e.target.value)}
className={cx(
'bg-lineHighlight text-foreground text-xs rounded-md border border-muted cursor-pointer',
'px-1.5 py-1 max-w-[9rem] outline-none transition-colors',
'hover:border-cyan-400/70 hover:text-cyan-300 focus:border-cyan-400',
)}
>
{Object.keys(themes).map((name) => (
<option key={name} value={name}>
🎨 {name}
</option>
))}
</select>
);
}
// selector de IDIOMA (header, a la derecha del tema)
function LanguageSelector() {
const { language } = useSettings();
const t = useT();
return (
<select
title={t('language')}
value={language || 'es'}
onChange={(e) => setLanguage(e.target.value)}
className={cx(
'bg-lineHighlight text-foreground text-xs rounded-md border border-muted cursor-pointer',
'px-1.5 py-1 outline-none transition-colors',
'hover:border-cyan-400/70 hover:text-cyan-300 focus:border-cyan-400',
)}
>
{Object.entries(LANGS).map(([code, label]) => (
<option key={code} value={code}>
🌍 {label}
</option>
))}
</select>
);
}
export function Footer({ context, isEmbedded = false }) {
return (
<div className="border-t border-muted bg-lineHighlight block lg:hidden">
@ -121,44 +186,45 @@ export function Footer({ context, isEmbedded = false }) {
);
}
// estilo compartido de los botones del menú: grandes, redondeados y con
// hover neón (fondo cian suave + texto cian + halo)
const menuBtn = cx(
'flex items-center gap-1.5 px-4 py-1.5 rounded-lg font-semibold tracking-wide',
'transition-all duration-150 cursor-pointer',
'hover:bg-cyan-400/15 hover:text-cyan-300 hover:shadow-[0_0_14px_rgba(34,211,238,0.35)] hover:scale-105',
);
function MainMenu({ context, isEmbedded = false, className }) {
const { started, pending, isDirty, activeCode, handleTogglePlay, handleEvaluate, handleShare } = context;
const { isCSSAnimationDisabled } = useSettings();
const t = useT();
return (
<div className={cx('flex text-sm max-w-full shrink-0 overflow-hidden text-foreground px-2 h-10', className)}>
<div
className={cx('flex items-center gap-1 text-sm max-w-full shrink-0 overflow-hidden text-foreground', className)}
>
<button
onClick={handleTogglePlay}
title={started ? 'stop' : 'play'}
className={cx('px-2 hover:opacity-50', !started && !isCSSAnimationDisabled && 'animate-pulse')}
title={started ? t('stop') : t('play')}
className={cx(menuBtn, !started && !isCSSAnimationDisabled && 'animate-pulse')}
>
<span className={cx('flex items-center space-x-2')}>
{started ? <StopIcon className="w-5 h-5" /> : <PlayIcon className="w-5 h-5" />}
{!isEmbedded && <span>{pending ? '...' : started ? 'stop' : 'play'}</span>}
</span>
{started ? <StopIcon className="w-6 h-6" /> : <PlayIcon className="w-6 h-6" />}
{!isEmbedded && <span>{pending ? '...' : started ? t('stop') : t('play')}</span>}
</button>
<button
onClick={handleEvaluate}
title="update"
className={cx('flex items-center space-x-1 px-2', !isDirty || !activeCode ? 'opacity-50' : 'hover:opacity-50')}
title={t('update')}
className={cx(menuBtn, (!isDirty || !activeCode) && 'opacity-50')}
>
{!isEmbedded && <span>update</span>}
{!isEmbedded && <span>{t('update')}</span>}
</button>
{!isEmbedded && (
<button
title="share"
className={cx('cursor-pointer hover:opacity-50 flex items-center space-x-1 px-2')}
onClick={handleShare}
>
<span>share</span>
<button title={t('share')} className={menuBtn} onClick={handleShare}>
<span>{t('share')}</span>
</button>
)}
{!isEmbedded && (
<a
title="learn"
href={`${baseNoTrailing}/workshop/getting-started/`}
className={cx('hover:opacity-50 flex items-center space-x-1', !isEmbedded ? 'p-2' : 'px-2')}
>
<span>learn</span>
<a title={t('learn')} href={`${baseNoTrailing}/workshop/getting-started/`} className={menuBtn}>
<span>{t('learn')}</span>
</a>
)}
</div>
@ -180,15 +246,55 @@ function PanelCloseButton() {
);
}
const BHKEY = 'strudel-bottompanel-h'; // alto (px) del panel inferior (redimensionable)
export function BottomPanel({ context }) {
const { isPanelOpen, activeFooter: tab } = useSettings();
const t = useT();
const [h, setH] = useState(() => {
try {
return Number(localStorage.getItem(BHKEY)) || 360;
} catch {
return 360;
}
});
// arrastrar el borde superior: encoge/agranda el panel (y el editor al revés)
const startDrag = (e) => {
e.preventDefault();
const startY = e.clientY;
const startH = h;
let last = startH;
const move = (ev) => {
const max = Math.round(window.innerHeight * 0.8);
last = Math.max(160, Math.min(max, startH + (startY - ev.clientY)));
setH(last);
};
const up = () => {
document.removeEventListener('mousemove', move);
document.removeEventListener('mouseup', up);
try {
localStorage.setItem(BHKEY, String(Math.round(last)));
} catch {
/* noop */
}
};
document.addEventListener('mousemove', move);
document.addEventListener('mouseup', up);
};
return (
<PanelNav
className={cx(
isPanelOpen ? `min-h-[360px] max-h-[360px]` : 'min-h-10 max-h-10',
'overflow-hidden flex flex-col relative',
)}
className={cx(!isPanelOpen && 'min-h-10 max-h-10', 'shrink-0 overflow-hidden flex flex-col relative')}
style={isPanelOpen ? { height: h } : undefined}
>
{isPanelOpen && (
<div
onMouseDown={startDrag}
title={t('resizeHint')}
className="h-1.5 w-full shrink-0 cursor-row-resize bg-muted hover:bg-cyan-400/60"
/>
)}
<div className="flex justify-between min-h-10 max-h-10 grid-cols-2 items-center border-t border-muted">
<PanelCloseButton />
<Tabs setTab={setTab} tab={tab} className={cx(isPanelOpen && 'border-l border-muted')} />
@ -202,19 +308,59 @@ export function BottomPanel({ context }) {
);
}
const RWKEY = 'strudel-rightpanel-w'; // ancho (px) del panel derecho (redimensionable)
export function RightPanel({ context }) {
const settings = useSettings();
const { activeFooter: tab, isPanelOpen } = settings;
const t = useT();
const [w, setW] = useState(() => {
try {
return Number(localStorage.getItem(RWKEY)) || 600;
} catch {
return 600;
}
});
if (!isPanelOpen) {
return;
}
// arrastrar el borde izquierdo: encoge el editor / agranda el panel (y al revés)
const startDrag = (e) => {
e.preventDefault();
const startX = e.clientX;
const startW = w;
let last = startW;
const move = (ev) => {
const max = Math.round(window.innerWidth * 0.9);
last = Math.max(260, Math.min(max, startW + (startX - ev.clientX)));
setW(last);
};
const up = () => {
document.removeEventListener('mousemove', move);
document.removeEventListener('mouseup', up);
try {
localStorage.setItem(RWKEY, String(Math.round(last)));
} catch {
/* noop */
}
};
document.addEventListener('mousemove', move);
document.addEventListener('mouseup', up);
};
return (
<div className="flex h-full shrink-0 overflow-hidden">
{/* divisor arrastrable entre editor y panel */}
<div
onMouseDown={startDrag}
title={t('resizeHint')}
className="w-1.5 h-full shrink-0 cursor-col-resize bg-muted hover:bg-cyan-400/60"
/>
<PanelNav
settings={settings}
className={cx(
'border-l border-muted shrink-0 h-full overflow-hidden',
isPanelOpen ? `min-w-[min(600px,100vw)] max-w-[min(600px,80vw)]` : 'min-w-12 max-w-12',
)}
className="border-l border-muted h-full overflow-hidden"
style={{ width: w, maxWidth: '90vw' }}
>
<div className={cx('flex flex-col h-full')}>
<div className="flex justify-between w-full overflow-hidden border-b border-muted min-h-10 max-h-10">
@ -226,10 +372,13 @@ export function RightPanel({ context }) {
</div>
</div>
</PanelNav>
</div>
);
}
const tabNames = {
projects: 'projects',
guía: 'guia',
welcome: 'intro',
patterns: 'patterns',
sounds: 'sounds',
@ -242,6 +391,21 @@ if (TAURI) {
tabNames.files = 'files';
}
// clave de traducción de la etiqueta visible de cada pestaña (el valor
// interno no cambia solo el texto que se muestra)
const tabLabelKeys = {
projects: 'tabProjects',
guia: 'tabGuide',
intro: 'tabIntro',
patterns: 'tabPatterns',
sounds: 'tabSounds',
reference: 'tabReference',
export: 'tabExport',
console: 'tabConsole',
settings: 'tabSettings',
files: 'tabFiles',
};
function PanelNav({ children, className, ...props }) {
const settings = useSettings();
return (
@ -263,6 +427,10 @@ function PanelNav({ children, className, ...props }) {
function PanelContent({ context, tab }) {
useLogger();
switch (tab) {
case tabNames.projects:
return <ProjectsTab context={context} />;
case tabNames.guia:
return <GuideTab />;
case tabNames.patterns:
return <PatternsTab context={context} />;
case tabNames.console:
@ -299,6 +467,7 @@ function PanelTab({ label, isSelected, onClick }) {
}
function Tabs({ className }) {
const { isPanelOpen, activeFooter: tab } = useSettings();
const t = useT();
return (
<div
className={cx(
@ -308,7 +477,8 @@ function Tabs({ className }) {
>
{Object.keys(tabNames).map((key) => {
const val = tabNames[key];
return <PanelTab key={key} isSelected={tab === val && isPanelOpen} label={key} onClick={() => setTab(val)} />;
const label = tabLabelKeys[val] ? t(tabLabelKeys[val]) : key;
return <PanelTab key={key} isSelected={tab === val && isPanelOpen} label={label} onClick={() => setTab(val)} />;
})}
</div>
);

View file

@ -0,0 +1,425 @@
import { atom } from 'nanostores';
import { useStore } from '@nanostores/react';
import { useMemo, useState } from 'react';
import cx from '@src/cx.mjs';
import { useT } from '@src/i18n.mjs';
import { useSettings } from '@src/settings.mjs';
import { ActionButton } from '../button/action-button.jsx';
import { Textbox } from './SettingsTab.jsx';
import { DEFAULT_PROJECTS, DEFAULT_VERSION } from './default-projects.mjs';
// Almacen propio de proyectos locales, independiente del autosave de patterns.
// Se persiste en localStorage bajo la clave 'strudel-projects'.
const KEY = 'strudel-projects';
const VKEY = 'strudel-projects-version';
const LHKEY = 'strudel-projects-listh'; // altura (px) de la lista de samples (redimensionable)
const FKEY = 'strudel-projects-folders-open'; // carpetas abiertas (persistente)
// carpeta para lo guardado sin carpeta + orden fijo de las de fabrica
const SIN_CARPETA = 'mis proyectos';
const FOLDER_ORDER = [
SIN_CARPETA,
'remixes 80s',
'remixes 90s',
'remixes 2000s',
'acid largo',
'techno estilos',
'acid conceptual',
'detalle',
'bases',
];
const load = () => {
try {
return JSON.parse(localStorage.getItem(KEY)) ?? [];
} catch {
return [];
}
};
// Siembra SOLO-AÑADIR: cuando sube DEFAULT_VERSION, agrega los tracks de fabrica
// que falten por nombre. NUNCA borra nada (ni los de fabrica ni los tuyos)
// la idea es ir acumulando una libreria para pinchar.
// Ademas rellena la carpeta (folder) de los tracks de fabrica ya guardados que
// no la tengan solo metadatos, nunca toca el codigo.
const migrate = (list) => {
try {
const v = Number(localStorage.getItem(VKEY) || 0);
if (v >= DEFAULT_VERSION) return list;
const folderByName = Object.fromEntries(DEFAULT_PROJECTS.map((p) => [p.name, p.folder]));
const withFolders = list.map((p) =>
p.folder == null && folderByName[p.name] != null ? { ...p, folder: folderByName[p.name] } : p,
);
const names = new Set(withFolders.map((p) => p.name));
const toAdd = DEFAULT_PROJECTS.filter((p) => !names.has(p.name)).map((p) => ({ ...p, factory: true }));
const merged = [...withFolders, ...toAdd.slice().reverse()];
localStorage.setItem(KEY, JSON.stringify(merged));
localStorage.setItem(VKEY, String(DEFAULT_VERSION));
return merged;
} catch {
return list;
}
};
export const $projects = atom(migrate(load()));
// nombre del sample cargado en el editor (compartido con la barra de transporte)
export const $playing = atom(null);
const persist = (list) => {
try {
localStorage.setItem(KEY, JSON.stringify(list));
} catch {
/* SSR / storage no disponible */
}
$projects.set(list);
};
// helpers de análisis del código (compartidos con TransportBar)
// evalua una expresion tipo "132/4", "132/60/4" (division encadenada) o "33"
const evalCpm = (expr) => {
const nums = String(expr).match(/[\d.]+/g);
if (!nums) return 30;
const hasDiv = /\//.test(String(expr));
if (!hasDiv || nums.length === 1) return parseFloat(nums[0]);
return nums.map(parseFloat).reduce((a, b) => a / b);
};
// saca BPM, cps y longitud del loop (en ciclos) de un track.
// - BPM = cpm * 4 (convencion setcpm(BPM/4))
// - loop: si hay arrange(...) => suma de ciclos de cada seccion; si no, la
// "variable mas larga" (mayor .slow(N) o el <...> con mas pasos). Default 1.
export const parseMeta = (code = '') => {
const scpm = code.match(/setcpm\s*\(([^)]*)\)/);
const scps = code.match(/setcps\s*\(([^)]*)\)/);
let cpm = 30;
if (scpm) cpm = evalCpm(scpm[1]);
else if (scps) cpm = evalCpm(scps[1]) * 60;
const bpm = Math.round(cpm * 4);
const cps = cpm / 60 || 0.5;
// para localizar el arrange() sin tropezar con la mini-notacion, quitamos strings
const stripped = code
.replace(/"[^"]*"/g, '""')
.replace(/'[^']*'/g, "''")
.replace(/`[^`]*`/g, '``');
let loop = null;
const ai = stripped.indexOf('arrange');
if (ai >= 0) {
const open = stripped.indexOf('(', ai);
if (open >= 0) {
let depth = 0;
let end = -1;
for (let j = open; j < stripped.length; j++) {
const c = stripped[j];
if (c === '(') depth++;
else if (c === ')') {
depth--;
if (depth === 0) {
end = j;
break;
}
}
}
if (end > open) {
const body = stripped.slice(open + 1, end);
let d = 0;
let sum = 0;
let found = false;
for (let k = 0; k < body.length; k++) {
const c = body[k];
if (c === '[') {
if (d === 0) {
const m = body.slice(k + 1).match(/^\s*([\d.]+)/);
if (m) {
sum += parseFloat(m[1]);
found = true;
}
}
d++;
} else if (c === ']') d = Math.max(0, d - 1);
}
if (found) loop = sum;
}
}
}
if (loop == null) {
let max = 1;
for (const m of code.matchAll(/\.slow\(\s*([\d.]+)\s*\)/g)) max = Math.max(max, parseFloat(m[1]));
for (const m of code.matchAll(/<([^<>]*)>/g)) {
const n = m[1].trim().split(/\s+/).filter(Boolean).length;
if (n) max = Math.max(max, n);
}
loop = max;
}
return { bpm, cps, loopCycles: loop || 1 };
};
// color del boton de BPM por tramos (para saber que mezcla bien con que)
export const bpmColor = (bpm) => {
if (bpm >= 145) return { bg: '#111827', fg: '#f9fafb' }; // negro (145+)
if (bpm >= 135) return { bg: '#f97316', fg: '#1a1a1a' }; // naranja (135-145)
if (bpm >= 125) return { bg: '#eab308', fg: '#1a1a1a' }; // amarillo (125-135)
if (bpm >= 115) return { bg: '#22c55e', fg: '#0a0a0a' }; // verde (115-125)
return { bg: '#3b82f6', fg: '#f9fafb' }; // azul (100-115 y menos)
};
export const fmt = (s) => {
if (!isFinite(s) || s < 0) s = 0;
const m = Math.floor(s / 60);
const sec = Math.floor(s % 60);
return `${m}:${String(sec).padStart(2, '0')}`;
};
export function ProjectsTab({ context }) {
const { fontFamily } = useSettings();
const t = useT();
const projects = useStore($projects);
const playingName = useStore($playing);
const [name, setName] = useState('');
const [copied, setCopied] = useState(null);
const [previewed, setPreviewed] = useState(null); // sample mostrado en la pantalla de lectura (NO se ejecuta)
const [listH, setListH] = useState(() => {
try {
return Number(localStorage.getItem(LHKEY)) || 200;
} catch {
return 200;
}
});
// carpetas abiertas (Set de nombres); persiste entre sesiones
const [openFolders, setOpenFolders] = useState(() => {
try {
return new Set(JSON.parse(localStorage.getItem(FKEY)) ?? []);
} catch {
return new Set();
}
});
const toggleFolder = (f) => {
setOpenFolders((prev) => {
const next = new Set(prev);
if (next.has(f)) next.delete(f);
else next.add(f);
try {
localStorage.setItem(FKEY, JSON.stringify([...next]));
} catch {
/* noop */
}
return next;
});
};
const editor = () => context.editorRef.current;
// BPM/loop de cada sample (memo, solo recalcula si cambia la lista)
const metas = useMemo(() => {
const out = {};
for (const p of projects) out[p.name] = parseMeta(p.code);
return out;
}, [projects]);
// agrupar por carpeta manteniendo el orden de la lista (mas reciente primero)
const folders = useMemo(() => {
const map = new Map();
for (const p of projects.slice().reverse()) {
const f = p.folder || SIN_CARPETA;
if (!map.has(f)) map.set(f, []);
map.get(f).push(p);
}
const pos = (f) => {
const i = FOLDER_ORDER.indexOf(f);
return i === -1 ? 0.5 : i; // carpetas propias desconocidas: tras "mis proyectos"
};
return [...map.entries()].sort((a, b) => pos(a[0]) - pos(b[0]) || a[0].localeCompare(b[0]));
}, [projects]);
const saveCurrent = () => {
const code = editor()?.code ?? '';
if (!code.trim()) return;
// "carpeta/nombre" guarda dentro de esa carpeta; sin barra va a "mis proyectos"
const raw = name.trim() || `project ${projects.length + 1}`;
const slash = raw.indexOf('/');
const folder = slash > 0 ? raw.slice(0, slash).trim() : undefined;
const title = (slash > 0 ? raw.slice(slash + 1).trim() : raw) || raw;
const rest = projects.filter((p) => p.name !== title);
persist([...rest, { name: title, code, created_at: Date.now(), ...(folder ? { folder } : {}) }]);
setName('');
};
const copyProject = (p) => {
navigator.clipboard?.writeText?.(p.code);
setCopied(p.name);
setTimeout(() => setCopied((c) => (c === p.name ? null : c)), 1200);
};
// click en el nombre = SOLO leer en la pantalla de abajo (no ejecuta, no toca lo que suena)
const preview = (p) => setPreviewed(p);
// cargar = reemplaza el editor con este track (no lo evalua; play/skip de la barra lo hacen)
const loadProject = (p) => {
const ed = editor();
if (!ed) return;
ed.setCode(p.code);
setPreviewed(p);
$playing.set(p.name);
};
const del = (p) => {
persist(projects.filter((x) => x.name !== p.name));
if (previewed?.name === p.name) setPreviewed(null);
};
const onKeyDown = (e) => {
if (e.key === 'Enter') saveCurrent();
};
// redimensionar la lista arrastrando el divisor
const startDrag = (e) => {
e.preventDefault();
const startY = e.clientY;
const startH = listH;
let lastH = startH;
const move = (ev) => {
lastH = Math.max(64, Math.min(720, startH + (ev.clientY - startY)));
setListH(lastH);
};
const up = () => {
document.removeEventListener('mousemove', move);
document.removeEventListener('mouseup', up);
try {
localStorage.setItem(LHKEY, String(Math.round(lastH)));
} catch {
/* noop */
}
};
document.addEventListener('mousemove', move);
document.addEventListener('mouseup', up);
};
return (
<div className="w-full h-full text-foreground flex flex-col overflow-hidden" style={{ fontFamily }}>
{/* guardar */}
<div className="flex gap-2 items-center px-2 py-2 border-b border-muted shrink-0">
<Textbox
className="w-full border-0"
placeholder={t('projectPlaceholder')}
value={name}
onChange={setName}
onKeyDown={onKeyDown}
/>
<ActionButton label={t('save')} onClick={saveCurrent} className="px-2 border border-muted rounded-sm" />
</div>
{/* LISTA de samples agrupada en carpetas plegables (altura redimensionable) */}
<div className="overflow-auto px-2 py-1 text-sm shrink-0" style={{ height: listH }}>
{projects.length === 0 && <p className="opacity-60 py-2">{t('noProjects')}</p>}
{folders.map(([folder, items]) => {
const open = openFolders.has(folder);
const sonando = playingName != null && items.some((p) => p.name === playingName);
return (
<div key={folder}>
{/* cabecera de carpeta: click = plegar/desplegar */}
<div
className={cx(
'flex items-center gap-2 py-1 cursor-pointer select-none sticky top-0 bg-background',
'border-b border-muted font-bold uppercase tracking-wide text-xs',
open ? 'text-cyan-300' : 'opacity-80 hover:opacity-100',
)}
title={open ? `plegar ${folder}` : `desplegar ${folder}`}
onClick={() => toggleFolder(folder)}
>
<span className="w-3 text-center">{open ? '▾' : '▸'}</span>
<span className="truncate">
📁 {folder === SIN_CARPETA ? t('myProjects') : folder}
{sonando && <span className="text-cyan-400 ml-1"></span>}
</span>
<span className="ml-auto opacity-50 tabular-nums normal-case">{items.length}</span>
</div>
{open &&
items.map((p) => {
const m = metas[p.name] || { bpm: 0 };
const col = bpmColor(m.bpm);
return (
<div
key={p.name}
className={cx(
'flex items-center gap-2 py-1 pl-4 border-b border-muted/40',
previewed?.name === p.name && 'bg-cyan-400/10',
playingName === p.name && 'ring-1 ring-cyan-400/40 rounded-sm',
)}
>
{/* copiar: a la IZQUIERDA y enmarcado, lejos de la ✕ para no borrar sin querer */}
<ActionButton
label={copied === p.name ? `${t('copied')}` : `${t('copy')}`}
onClick={() => copyProject(p)}
className={cx(
'px-2 py-0.5 border rounded shrink-0 font-bold',
copied === p.name
? 'border-green-400 text-green-400'
: 'border-cyan-400/70 text-cyan-300 hover:bg-cyan-400/10',
)}
/>
<span
className="truncate flex-1 cursor-pointer"
title={`${t('read')} ${p.name}`}
onClick={() => preview(p)}
>
{p.name}
</span>
{/* BPM: a la DERECHA, boton coloreado por tramo (mezcla) */}
<span
className="text-[10px] font-bold px-1.5 py-0.5 rounded shrink-0 tabular-nums"
title={`${m.bpm} BPM`}
style={{ backgroundColor: col.bg, color: col.fg }}
>
{m.bpm}
</span>
<ActionButton
label={t('load')}
onClick={() => loadProject(p)}
className="px-2 opacity-70 hover:opacity-100 shrink-0"
/>
<ActionButton
label="✕"
onClick={() => del(p)}
className="text-red-400 px-1 opacity-40 hover:opacity-100 shrink-0"
/>
</div>
);
})}
</div>
);
})}
</div>
{/* DIVISOR arrastrable (redimensiona lista vs. pantalla de lectura) */}
<div
onMouseDown={startDrag}
title="arrastra para redimensionar"
className="h-1.5 shrink-0 cursor-row-resize bg-muted hover:bg-cyan-400/60"
/>
{/* PANTALLA DE LECTURA (preview): muestra el sample clicado SIN ejecutar nada */}
<div className="grow overflow-auto px-2 py-2 min-h-0">
{previewed ? (
<>
<div className="flex items-center gap-2 mb-1">
<span className="text-xs opacity-70 truncate">
{t('previewTitle')} · {previewed.name}
</span>
<ActionButton
label={t('loadInEditor')}
onClick={() => loadProject(previewed)}
className="ml-auto text-cyan-300 border border-cyan-400/60 rounded px-1.5 text-xs shrink-0"
/>
</div>
<pre className="font-mono text-xs whitespace-pre-wrap leading-snug opacity-90 select-text">
{previewed.code}
</pre>
</>
) : (
<p className="opacity-50 text-xs">{t('previewHelp')}</p>
)}
</div>
</div>
);
}

View file

@ -0,0 +1,654 @@
// Librería de tracks de fábrica para la pestaña Projects.
// Filosofía: IR AÑADIENDO (para pinchar). La siembra es SOLO-AÑADIR: nunca borra
// nada; al subir DEFAULT_VERSION se agregan los que falten por nombre.
//
// Familias:
// base NN → loops clásicos, foco en el groove (los primeros que hicimos)
// acid N → piezas largas que evolucionan sobre una tensión conceptual
// detalle → showcase de instrumentos/sonidos (rhodes, piano, pads, vibráfono, FM)
// techno · → temas ~7 min con arrange() (intro→capas→drop→break→outro)
// acid largo· temas ~7 min con estructura de directo: intro melódica → drop →
// beat melódico (piano/flauta) → subida → clímax (todo junto + 2º
// beat + melodía rompiendo ~50s) → descenso ~35s. Cajas VARIADAS.
//
// Para añadir tu propio pack local sin tocar el repo: crea un modulo con tus
// proyectos (mismo formato {name, folder, code}) y concatenalo abajo en
// DEFAULT_PROJECTS. Los packs personales van fuera del control de versiones.
export const DEFAULT_VERSION = 6;
const CLASSICS = [
{
name: 'base 01 · 303 clásico',
code: `// base 01 · 303 clásico (acid house)
setcpm(130/4)
$: s("bd*4").bank("RolandTR909").gain(1.1)
$: s("~ cp ~ cp").bank("RolandTR909").gain(.55)
$: s("hh*8").bank("RolandTR909").gain(.4).pan(sine.range(.35,.65))
$: note("c2 c2 eb2 c2 g1 c2 bb1 c2").fast(2)
.s("sawtooth").lpf(sine.range(300,1900).slow(8)).lpq(12).lpenv(4).lpd(.12)
.distort("1.3:.35").gain(.85)`,
},
{
name: 'base 02 · hard acid',
code: `// base 02 · hard acid techno
setcpm(138/4)
$: s("bd*4").bank("RolandTR909").gain(1.2).shape(.3)
$: s("~ ~ cp ~").bank("RolandTR909").gain(.5).room(.2)
$: s("hh*16").bank("RolandTR909").gain(perlin.range(.1,.5))
$: note("c2 c2 c3 eb2 c2 g2 f2 c2").fast(2)
.s("sawtooth").lpf(saw.range(400,2600).slow(4)).lpq(14).lpenv(5).lpd(.09)
.distort("2:.3").crush(8).gain(.8)`,
},
{
name: 'base 03 · hipnótico',
code: `// base 03 · hipnótico rolling
setcpm(132/4)
$: s("bd*4").bank("RolandTR909")
$: s("hh*8").bank("RolandTR909").gain(.35).pan(.4)
$: s("~ oh ~ ~").bank("RolandTR909").gain(.3)
$: note("c2 eb2 g2 c3 bb2 g2 eb2 c2").fast(2)
.s("sawtooth").lpf(sine.range(500,1400).slow(16)).lpq(10).lpenv(3).lpd(.15)
.distort("1:.4").room(.15).gain(.8)`,
},
{
name: 'base 04 · deep dub',
code: `// base 04 · deep dub acid
setcpm(126/4)
$: s("bd*4").bank("RolandTR808").gain(1.1)
$: s("~ cp").bank("RolandTR808").gain(.4).room(.4).size(4)
$: s("hh*8").bank("RolandTR808").gain(.3)
$: note("c2 ~ c2 eb2 ~ g1 c2 ~").fast(2)
.s("sawtooth").lpf(sine.range(250,900).slow(12)).lpq(9).lpenv(3).lpd(.2)
.delay(.4).dt(.375).dfb(.4).room(.3).gain(.8)`,
},
{
name: 'base 05 · screaming',
code: `// base 05 · screaming acid
setcpm(140/4)
$: s("bd*4").bank("RolandTR909").gain(1.2)
$: s("cp*2").bank("RolandTR909").gain(.4).degradeBy(.5)
$: s("hh*16").bank("RolandTR909").gain(.3)
$: note("c3 c3 eb3 c3 bb2 c3 g2 c3").fast(2)
.s("square").lpf(sine.range(600,3500).slow(6)).lpq(18).lpenv(6).lpd(.08)
.distort("2.5:.25").gain(.7)`,
},
{
name: 'base 06 · rave stabs',
code: `// base 06 · rave acid
setcpm(135/4)
$: s("bd*4").bank("RolandTR909").gain(1.1)
$: s("~ cp").bank("RolandTR909").gain(.55)
$: s("hh*8").bank("RolandTR909").gain(.4)
$: note("c3,eb3,g3").struct("x ~ x x ~ x ~ ~").s("supersaw").lpf(1200).room(.3).gain(.45)
$: note("c2 c2 eb2 g2 c2 bb1 c2 g1").fast(2)
.s("sawtooth").lpf(saw.range(400,2000).slow(8)).lpq(13).lpenv(4).lpd(.1)
.distort("1.5:.3").gain(.8)`,
},
{
name: 'base 07 · minimal',
code: `// base 07 · minimal acid
setcpm(128/4)
$: s("bd*4").bank("RolandTR909").gain(1.1)
$: s("~ ~ ~ hh").bank("RolandTR909").gain(.4)
$: note("c2 ~ ~ c2 ~ eb2 ~ ~").fast(2)
.s("sawtooth").lpf(sine.range(300,1600).slow(10)).lpq(11).lpenv(4).lpd(.14)
.distort("1:.4").gain(.85)`,
},
{
name: 'base 08 · rumble',
code: `// base 08 · rumble acid techno
setcpm(134/4)
$: s("bd*4").bank("RolandTR909").gain(1.2).shape(.4)
$: s("bd*4").bank("RolandTR909").lpf(120).room(.6).size(6).gain(.7)
$: s("hh*16").bank("RolandTR909").gain(.25)
$: note("c2 c2 c2 eb2 c2 g1 c2 bb1").fast(2)
.s("sawtooth").lpf(sine.range(350,1700).slow(8)).lpq(13).lpenv(4).lpd(.1)
.distort("1.8:.3").gain(.75)`,
},
{
name: 'base 09 · detroit',
code: `// base 09 · detroit acid
setcpm(130/4)
$: s("bd*4").bank("RolandTR909").gain(1.1)
$: s("~ cp").bank("RolandTR909").gain(.5)
$: s("hh*8").bank("RolandTR909").gain(.35).pan(sine.range(.3,.7))
$: note("<[c3,eb3,g3] [bb2,d3,f3]>").struct("x ~ x ~ ~ x ~ ~").s("sawtooth").lpf(1500).lpq(6).room(.4).gain(.4)
$: note("c2 eb2 g2 c2 bb1 c2 g1 c2").fast(2)
.s("sawtooth").lpf(sine.range(400,1800).slow(8)).lpq(12).lpenv(4).lpd(.11)
.distort("1.2:.35").gain(.8)`,
},
{
name: 'base 10 · fast groove',
code: `// base 10 · fast hard acid
setcpm(142/4)
$: s("bd*4").bank("RolandTR909").gain(1.2).shape(.3)
$: s("~ cp ~ cp").bank("RolandTR909").gain(.45)
$: s("hh*16").bank("RolandTR909").gain(perlin.range(.15,.45))
$: s("~ oh").bank("RolandTR909").gain(.3).degradeBy(.3)
$: note("c2 c2 eb2 c2 f2 c2 g2 bb1").fast(2)
.s("sawtooth").lpf(saw.range(450,2800).slow(4)).lpq(15).lpenv(5).lpd(.08)
.distort("2.2:.25").crush(7).gain(.78)`,
},
];
const CONCEPTS = [
{
name: 'acid 1 · persistencia / efímero',
code: `// acid 1 · persistencia / efímero
// el bombo y el bajo PERMANECEN; todo lo demás aparece y se desvanece
setcpm(130/4)
$: s("bd*4").bank("RolandTR909").gain(1.1)
$: note("c2 c2 eb2 c2 g1 c2 bb1 c2").fast(2)
.s("sawtooth").lpf(sine.range(450,1500).slow(32)).lpq(11).lpenv(4).lpd(.12)
.distort("1.2:.4").gain(.8)
$: s("hh*16").bank("RolandTR909").gain(perlin.range(0,.5))
.degradeBy("<.2 .5 .8 .4>".slow(4))
$: s("cp").bank("RolandTR909").gain(.4).mask("<0 0 1 0 1 1 0 1>")
$: note("c5 eb5 g5 bb4").fast(2)
.s("sawtooth").lpf(2600).gain(.22).room(.5).delay(.4)
.degradeBy(.7).mask("<0 1 0 0 1 0>")`,
},
{
name: 'acid 2 · expansión / contención',
code: `// acid 2 · expansión / contención
setcpm(132/4)
$: s("bd*4").bank("RolandTR909").gain(1.15)
$: s("~ cp").bank("RolandTR909").gain(.45).room(sine.range(.1,.6).slow(32))
$: s("hh*8").bank("RolandTR909").gain(.35).pan(sine.range(.2,.8).slow(16))
$: note("c2 eb2 g2 c3 bb2 g2 eb2 c2").fast(2)
.s("sawtooth").lpf(sine.range(300,3200).slow(32)).lpq(sine.range(6,16).slow(32))
.lpenv(4).lpd(.12).distort("1.3:.35")
.room(sine.range(0,.4).slow(32)).gain(.8)`,
},
{
name: 'acid 3 · caos / orden',
code: `// acid 3 · caos / orden
setcpm(136/4)
$: s("bd*4").bank("RolandTR909").gain(1.15).degradeBy("<0 0 0 .3 0 .5>".slow(2))
$: s("hh*16").bank("RolandTR909").gain(.3).degradeBy(sine.range(0,.8).slow(24))
$: s("rim*8").bank("RolandTR909").euclid("<4 5 7 3 8 5>",8).gain(.35)
$: note("c2 eb2 g2 c2 bb1 c2 g1 c2").fast(2)
.s("sawtooth").lpf(saw.range(400,2200).slow(8)).lpq(13).lpenv(4).lpd(.1)
.distort("1.6:.3")
.someCyclesBy(.35, x=>x.ply("<1 2 3>").speed(rand.range(.9,1.1)))
.gain(.8)`,
},
{
name: 'acid 4 · tensión / disolución',
code: `// acid 4 · tensión / disolución
setcpm(138/4)
$: s("bd*4").bank("RolandTR909").gain(1.2).mask("<1 1 1 1 1 1 1 0>")
$: s("hh*16").bank("RolandTR909").gain(saw.range(.1,.5).slow(16))
$: s("~ cp").bank("RolandTR909").gain(.4)
$: note("c2 c2 eb2 c2 g2 c2 bb1 c2").fast(2)
.s("sawtooth").lpf(saw.range(500,3500).slow(16)).lpq(saw.range(8,20).slow(16))
.lpenv(4).lpd(.1).distort("1.8:.3")
.every(16, x=>x.room(.7).gain(.45).lpf(600)).gain(.8)`,
},
{
name: 'acid 5 · densidad / vacío',
code: `// acid 5 · densidad / vacío
setcpm(128/4)
$: s("bd*4").bank("RolandTR808").gain(1.1).mask("<1 1 1 1 0 0 1 1>")
$: s("hh*16").bank("RolandTR808").gain(.3).degradeBy(sine.range(0,.9).slow(16))
$: note("c2 eb2 g1 c2 bb1 eb2 g2 c2").fast(2)
.s("sawtooth").lpf(sine.range(350,1500).slow(16)).lpq(10).lpenv(3).lpd(.15)
.ply("<1 1 2 1 4 1 1 2>".slow(2)).distort("1.3:.4").mask("<1 1 1 1 0 1 1 1>").gain(.8)
$: note("g4 bb4 c5").fast(2)
.s("square").lpf(2200).gain(.18).degradeBy(.6).room(.4).mask("<0 0 1 1 0 0 0 1>")`,
},
{
name: 'acid 6 · mecánico / orgánico',
code: `// acid 6 · mecánico / orgánico
setcpm(134/4)
$: s("bd*4").bank("RolandTR909").gain(1.15)
$: s("hh*16").bank("RolandTR909").gain(perlin.range(.15,.45)).swingBy(.1, 16)
$: s("~ cp").bank("RolandTR909").gain(.4)
$: note("c2 c2 eb2 c2 g1 c2 f2 c2").fast(2)
.s("sawtooth").lpf(sine.range(400,1800).slow(20).add(perlin.range(-200,200))).lpq(12)
.lpenv(4).lpd(.11).distort("1.4:.35").gain(perlin.range(.7,.9))`,
},
{
name: 'acid 7 · ascenso / caída',
code: `// acid 7 · ascenso / caída
setcpm(140/4)
$: s("bd*4").bank("RolandTR909").gain(1.2)
$: s("hh*8").bank("RolandTR909").gain(.35)
$: s("~ cp ~ cp").bank("RolandTR909").gain(.4)
$: note("c2 eb2 g2 c2 bb1 c2 g1 c2").fast(2)
.add(note("<0 0 5 5 7 7 12 7 5 0 -5 0>".slow(2)))
.s("sawtooth").lpf(sine.range(500,2600).slow(24)).lpq(13).lpenv(4).lpd(.1)
.distort("1.6:.3").gain(.78)`,
},
{
name: 'acid 8 · presencia / ausencia',
code: `// acid 8 · presencia / ausencia
setcpm(130/4)
$: s("bd*4").bank("RolandTR909").gain(1.1).mask("<1 1 1 1 1 1 0 0>")
$: s("hh*16").bank("RolandTR909").gain(.28).mask("<1 1 0 1 1 0 1 0>")
$: note("c2 eb2 g2 c3 g2 eb2 bb1 c2").fast(2)
.s("sawtooth").lpf(sine.range(400,1800).slow(24)).lpq(12).lpenv(4).lpd(.12)
.distort("1.3:.35").room(.35).delay(.5).dt(.5).dfb(.5)
.mask("<1 1 0 0 1 1 1 0>").gain(.82)`,
},
{
name: 'acid 9 · repetición / mutación',
code: `// acid 9 · repetición / mutación
setcpm(133/4)
$: s("bd*4").bank("RolandTR909").gain(1.15)
$: s("hh*16").bank("RolandTR909").gain(.3).every(4, x=>x.ply(2))
$: s("~ cp").bank("RolandTR909").gain(.4)
$: note("c2 c2 eb2 c2 g1 c2 bb1 c2").fast(2)
.s("sawtooth")
.every(8, x=>x.rev())
.every(6, x=>x.add(note("<0 3 5>")))
.chunk(4, x=>x.speed(1.5))
.lpf(sine.range(400,2000).slow(16)).lpq(13).lpenv(4).lpd(.1)
.distort("1.5:.3").gain(.8)`,
},
{
name: 'acid 10 · luz / sombra',
code: `// acid 10 · luz / sombra
setcpm(137/4)
$: s("bd*4").bank("RolandTR909").gain(1.2).shape(sine.range(0,.4).slow(32))
$: s("hh*16").bank("RolandTR909").gain(sine.range(.05,.45).slow(24))
$: s("~ cp").bank("RolandTR909").gain(.4)
$: note("c2 c2 eb2 c2 g1 c2 bb1 c2").fast(2)
.s("sawtooth").lpf(sine.range(250,3200).slow(32)).lpq(sine.range(8,16).slow(32))
.lpenv(sine.range(2,6).slow(32)).lpd(.1).distort("1.5:.3")
.room(sine.range(.05,.3).slow(32)).gain(.8)`,
},
];
const DETALLE = [
{
name: 'detalle · detroit rhodes',
code: `// detalle · detroit rhodes
// acordes de Rhodes (gm_epiano2) sobre un groove profundo + ácido suave
setcpm(126/4)
$: s("bd*4").bank("RolandTR909").gain(1.05)
$: s("~ cp").bank("RolandTR909").gain(.4)
$: s("hh*8").bank("RolandTR909").gain(.3).pan(sine.range(.4,.6))
$: note("<[c3,eb3,g3,bb3] [f3,ab3,c4] [g3,bb3,d4] [eb3,g3,bb3]>")
.s("gm_epiano2").room(.45).gain(.5).lpf(2600).velocity(.8)
$: note("c2 eb2 g2 c2 bb1 c2 g1 c2").fast(2)
.s("sawtooth").lpf(sine.range(400,1600).slow(16)).lpq(11).lpenv(4).lpd(.12)
.distort("1.1:.4").gain(.7)`,
},
{
name: 'detalle · deep piano + sub',
code: `// detalle · deep piano + sub
// piano melancólico (stabs) sobre un sub-bajo seno redondo
setcpm(124/4)
$: s("bd*4").bank("RolandTR808").gain(1.1)
$: s("~ ~ ~ hh").bank("RolandTR808").gain(.3)
$: note("c1 ~ c1 ~ eb1 ~ g0 ~").fast(2).s("sine").lpf(220).gain(.9)
$: note("<c4 eb4 g4 ~ bb3 ~ g4 f4>")
.s("piano").room(.5).gain(.45).delay(.3).dt(.33).degradeBy(.2)
$: note("c2 eb2 g2 c2").fast(2)
.s("sawtooth").lpf(1200).lpq(9).lpenv(3).lpd(.15).distort("1:.4").gain(.55)`,
},
{
name: 'detalle · cosmos pad + coro',
code: `// detalle · cosmos pad + coro
// pad cálido que respira y un coro etéreo que aparece a ratos
setcpm(128/4)
$: s("bd*4").bank("RolandTR909").gain(1.05)
$: s("hh*8").bank("RolandTR909").gain(.25).mask("<1 1 0 1>")
$: note("<c3 eb3 g2 ab2>").s("gm_pad_warm").slow(2)
.lpf(sine.range(600,2200).slow(24)).room(.6).gain(.5)
$: note("<[c4,g4] [eb4,bb4]>").s("gm_choir_aahs").slow(4)
.room(.7).gain(.3).mask("<0 1 0 1>")
$: note("c2 eb2 g2 c2 bb1 c2 g1 c2").fast(2)
.s("sawtooth").lpf(sine.range(400,1500).slow(20)).lpq(11).lpenv(4).lpd(.12)
.distort("1.1:.4").gain(.68)`,
},
{
name: 'detalle · cristales vibra + FM',
code: `// detalle · cristales vibra + FM
// vibráfono cristalino + campanitas FM sobre techno acid
setcpm(132/4)
$: s("bd*4").bank("RolandTR909").gain(1.1)
$: s("~ cp").bank("RolandTR909").gain(.4)
$: s("hh*16").bank("RolandTR909").gain(.25)
$: note("c5 eb5 g5 bb5 g5 eb5 c5 g4")
.s("gm_vibraphone").room(.5).gain(.35).delay(.4).dt(.375).degradeBy(.3)
$: note("c6 g5").struct("x ~ ~ x ~ x ~ ~")
.s("sine").fm(4).fmh(2).gain(.2).room(.6).degradeBy(.4)
$: note("c2 eb2 g2 c2 bb1 c2 g1 c2").fast(2)
.s("sawtooth").lpf(sine.range(400,1800).slow(16)).lpq(12).lpenv(4).lpd(.1)
.distort("1.4:.35").gain(.7)`,
},
];
// 8 subgéneros de techno, largos (~7 min con arrange: intro→capas→drop→break→outro),
// bancos variados combinados y teoría moderna (modos menores/frigios, armonía modal,
// capas de bombo, rumble, sidechain con gain).
const ESTILOS = [
{
name: 'techno · acid',
code: `// techno · acid — Do frigio · 138 BPM · ~7 min
// bancos: RolandTR909 (bombo/clap) + RolandTR606 (hats, la pareja de la TB-303)
setcpm(138/4)
const kick = s("bd*4").bank("RolandTR909").gain(1.1)
const clap = s("~ cp").bank("RolandTR909").gain(.5)
const hats = s("hh*16").bank("RolandTR606").gain(perlin.range(.12,.4))
const ohh = s("~ oh ~ oh").bank("RolandTR606").gain(.3)
const acid = note("c2 c2 db2 c2 g1 c2 ab1 c2").fast(2)
.s("sawtooth").lpf(sine.range(400,2600).slow(16)).lpq(15).lpenv(5).lpd(.11).distort("1.6:.3")
const acidHi = acid.add(note(12)).gain(.5).degradeBy(.4).delay(.3)
arrange(
[16, kick],
[16, stack(kick, hats)],
[24, stack(kick, hats, clap)],
[32, stack(kick, hats, clap, acid)],
[24, stack(hats, acid.room(.6).gain(.7))],
[64, stack(kick, hats, ohh, clap, acid, acidHi)],
[48, stack(kick, hats, clap, acid)],
[16, stack(kick, hats.gain(.2))]
)`,
},
{
name: 'techno · detroit',
code: `// techno · detroit — La menor · 125 BPM · ~7 min
// bancos: RolandTR909 (bombo) + RolandTR808 (clap) + RolandTR707 (hats). Rhodes + cuerdas
setcpm(125/4)
const kick = s("bd*4").bank("RolandTR909").gain(1.05)
const clap = s("~ cp").bank("RolandTR808").gain(.45)
const hats = s("hh*8").bank("RolandTR707").gain(.3).pan(sine.range(.4,.6))
const ride = s("~ ~ oh ~").bank("RolandTR707").gain(.25)
const rhodes = n("<0 3 5 4>").scale("A3:minor")
.off(.25, x=>x.add(note("7,12"))).s("gm_epiano2").room(.4).gain(.4).lpf(2600)
const strings = n("<0 5 3 4>").scale("A4:minor").s("gm_string_ensemble_1").slow(2).room(.6).gain(.26)
const bass = note("a1 ~ a1 c2 ~ e2 g1 ~").fast(2).s("sawtooth").lpf(700).lpq(6).lpenv(3).lpd(.14).gain(.7)
arrange(
[16, kick],
[16, stack(kick, hats)],
[24, stack(kick, hats, bass)],
[32, stack(kick, hats, clap, bass, rhodes)],
[24, stack(hats, rhodes.room(.7), strings)],
[32, stack(kick, hats, ride, clap, bass, rhodes, strings)],
[48, stack(kick, hats, clap, bass, rhodes)],
[16, stack(kick, rhodes.gain(.3))]
)`,
},
{
name: 'techno · dub',
code: `// techno · dub — Do menor · 122 BPM · ~7 min (Basic Channel)
// bancos: RolandTR808 (bombo) + RolandTR606 (rim/hats). Acordes con delay dub infinito
setcpm(122/4)
const kick = s("bd*4").bank("RolandTR808").gain(1.1)
const rim = s("~ rim").bank("RolandTR606").gain(.3)
const hats = s("hh*8").bank("RolandTR606").gain(.2).degradeBy(.3)
const chord = note("<[c3,eb3,g3] [bb2,d3,f3]>").struct("~ x ~ ~")
.s("sawtooth").lpf(sine.range(500,1300).slow(24)).lpq(8)
.room(.7).delay(.6).dt(.75).dfb(.6).gain(.32)
const sub = note("c1 ~ ~ ~ eb1 ~ ~ ~").fast(2).s("sine").lpf(180).gain(.9)
const hiss = s("white*8").gain(perlin.range(0,.12)).hpf(6000)
arrange(
[16, stack(kick, sub)],
[16, stack(kick, sub, hats)],
[24, stack(kick, sub, hats, rim)],
[32, stack(kick, sub, hats, rim, chord)],
[24, stack(chord.room(.85).gain(.4), hiss, sub)],
[40, stack(kick, sub, hats, rim, chord, hiss)],
[48, stack(kick, sub, hats, chord)],
[16, stack(kick, chord.gain(.25))]
)`,
},
{
name: 'techno · hard / schranz',
code: `// techno · hard/schranz — 145 BPM · ~7 min · bombo en capas + rumble + distorsión
// bancos: RolandTR909 + RolandTR808 (bombo doble) + LinnDrum (clap)
setcpm(145/4)
const kick = s("bd*4").bank("RolandTR909").gain(1.2).shape(.35)
const sub = s("bd*4").bank("RolandTR808").lpf(90).gain(.7)
const rumble = s("bd*4").bank("RolandTR909").lpf(150).room(.7).size(8).gain(.55)
const hats = s("hh*16").bank("RolandTR909").gain(.35).degradeBy(.1)
const clap = s("~ cp ~ cp").bank("LinnDrum").gain(.4)
const stab = note("c2 c2 c2 eb2").fast(4).s("sawtooth").lpf(saw.range(500,3000).slow(4)).lpq(12).distort("2.4:.25").crush(6).gain(.7)
const riser = s("white*4").gain(saw.range(0,.5).slow(16)).hpf(4000)
arrange(
[16, stack(kick, sub)],
[16, stack(kick, sub, hats)],
[24, stack(kick, sub, rumble, hats)],
[32, stack(kick, sub, rumble, hats, clap, stab)],
[24, stack(hats, stab.room(.6), riser)],
[76, stack(kick, sub, rumble, hats, clap, stab)],
[48, stack(kick, sub, rumble, hats, stab)],
[16, stack(kick, hats.gain(.2))]
)`,
},
{
name: 'techno · minimal',
code: `// techno · minimal — Do menor · 128 BPM · ~7 min · hueco e hipnótico
// bancos: RolandTR808 (bombo) + CasioRZ1 (click) + RolandTR727 (perc) + RolandTR606 (hats)
setcpm(128/4)
const kick = s("bd*4").bank("RolandTR808").gain(1.05)
const click = s("~ ~ rim ~").bank("CasioRZ1").gain(.4)
const perc = s("~ lt ~ ht").bank("RolandTR727").gain(.3).pan(sine.range(.3,.7).slow(8))
const hats = s("hh*8").bank("RolandTR606").gain(.22).degradeBy(sine.range(.1,.6).slow(16))
const blip = note("c4 ~ eb4 ~ ~ g3 ~ ~").fast(2).s("square").lpf(1400).lpq(8).gain(.25).delay(.4).dt(.375).dfb(.4)
const bass = note("c2 ~ ~ c2 ~ ~ eb2 ~").fast(2).s("sine").lpf(400).gain(.7)
arrange(
[16, stack(kick, bass)],
[16, stack(kick, bass, hats)],
[24, stack(kick, bass, hats, click)],
[32, stack(kick, bass, hats, click, perc, blip)],
[24, stack(bass, hats, blip.room(.6))],
[44, stack(kick, bass, hats, click, perc, blip)],
[48, stack(kick, bass, hats, perc, blip)],
[16, stack(kick, bass)]
)`,
},
{
name: 'techno · industrial',
code: `// techno · industrial — Do frigio · 132 BPM · ~7 min · oscuro, metálico, ruido
// bancos: RolandTR909 (bombo distorsionado) + EmuSP12 (perc). FM metálico + drone
setcpm(132/4)
const kick = s("bd*4").bank("RolandTR909").gain(1.2).shape(.5).distort("1.5:.4")
const metal = note("c5 ~ g4 ~").fast(2).s("sine").fm(9).fmh(3.51).gain(.28).room(.5).delay(.3)
const perc = s("~ rim ~ rim").bank("EmuSP12").gain(.35).crush(6)
const noise = s("white*16").gain(perlin.range(.05,.3)).hpf(3000).lpf(9000)
const drone = note("c1").s("sawtooth").lpf(sine.range(120,500).slow(24)).lpq(10).distort("1.2:.4").gain(.6)
const scream = note("c3 db3 c3 eb3").fast(2).s("square").lpf(saw.range(400,4000).slow(8)).lpq(16).distort("2.2:.2").gain(.5)
arrange(
[16, stack(kick, drone)],
[16, stack(kick, drone, noise)],
[24, stack(kick, drone, noise, perc)],
[32, stack(kick, drone, noise, perc, metal)],
[24, stack(drone, noise.gain(.4), metal.room(.8))],
[56, stack(kick, drone, noise, perc, metal, scream)],
[48, stack(kick, drone, noise, perc, scream)],
[16, stack(kick, drone)]
)`,
},
{
name: 'techno · hipnótico (rolling)',
code: `// techno · hipnótico rolling — La menor · 134 BPM · ~7 min (Berlin/Berghain)
// bancos: RolandTR909 + AlesisHR16 + RolandTR727. Polimetría 3-contra-4
setcpm(134/4)
const kick = s("bd*4").bank("RolandTR909").gain(1.1)
const hats = s("hh*16").bank("RolandTR909").gain(.28)
const roll = s("lt*3").bank("AlesisHR16").gain(.3).pan(sine.range(.35,.65))
const clap = s("~ cp").bank("RolandTR909").gain(.4)
const perc = s("mt ~ ht ~ ~ lt").bank("RolandTR727").gain(.28)
const bass = note("a1 a1 a1 c2 a1 a1 g1 a1").fast(2).s("sawtooth").lpf(sine.range(300,900).slow(32)).lpq(8).lpenv(3).lpd(.12).gain(.72)
const stab = note("a3,c4,e4").struct("~ ~ x ~ ~ ~ ~ x").s("sawtooth").lpf(1500).lpq(6).room(.5).gain(.3).degradeBy(.3)
arrange(
[16, stack(kick, bass)],
[16, stack(kick, bass, hats)],
[24, stack(kick, bass, hats, roll)],
[32, stack(kick, bass, hats, roll, clap, perc)],
[24, stack(bass, hats, roll, stab.room(.7))],
[52, stack(kick, bass, hats, roll, clap, perc, stab)],
[48, stack(kick, bass, hats, roll, perc)],
[16, stack(kick, bass)]
)`,
},
{
name: 'techno · melódico',
code: `// techno · melódico — Fa menor · 124 BPM · ~7 min (Afterlife/Tale of Us)
// bancos: RolandTR909 (bombo suave) + RolandTR808 (hats/clap). Arp + pad, progresión i-VI-III-VII
setcpm(124/4)
const kick = s("bd*4").bank("RolandTR909").gain(1.0)
const hats = s("hh*8").bank("RolandTR808").gain(.28).pan(sine.range(.4,.6))
const clap = s("~ cp").bank("RolandTR808").gain(.35).room(.3)
const arp = n("0 2 4 7 4 2").scale("<F:minor Db:major Ab:major Eb:major>")
.s("supersaw").fast(2).lpf(sine.range(700,2600).slow(16)).lpq(6).room(.5).delay(.4).dt(.375).gain(.3)
const pad = n("0").scale("<F3:minor Db3:major Ab3:major Eb3:major>")
.off(.5, x=>x.add(note("3,7"))).s("gm_pad_warm").slow(2).room(.7).gain(.4).lpf(sine.range(500,1800).slow(32))
const bass = note("<f1 db1 ab1 eb1>").s("sine").lpf(400).gain(.85)
arrange(
[16, stack(pad)],
[16, stack(kick, pad)],
[24, stack(kick, hats, bass, pad)],
[32, stack(kick, hats, clap, bass, pad, arp)],
[24, stack(pad, arp.room(.8), bass)],
[36, stack(kick, hats, clap, bass, pad, arp)],
[48, stack(kick, hats, bass, pad, arp)],
[16, stack(pad.gain(.3))]
)`,
},
];
// Temas de acid LARGOS con estructura de directo (intro melódica → drop →
// beat melódico → subida → clímax → descenso). Cada uno en otra tonalidad,
// con otras cajas de ritmos y otro instrumento melódico. Verificados headless.
const LARGOS = [
{
name: 'acid largo · rojo · Cm 132 (909/606/808)',
code: `// acid rojo · Do menor · 132 BPM · ~7:16
// intro melódico → drop → beat+piano → flauta pegadiza → subida → clímax → descenso
setcpm(132/4)
const acid = note("c1 [c2 c1] eb1 c1 g1 c1 [bb1 c2] g1").s("sawtooth")
.lpf(sine.range(450,2200).slow(4)).lpq(15).lpenv(5).lpa(.01).lpd(.14).lps(0).distort("1.3:.35")
const acidBuild = acid.lpf(saw.range(450,3200).slow(40)).lpq(16).distort("1.4:.35")
const acidClimax = acid.lpf(sine.range(700,3200).slow(2)).lpq(17).distort("1.6:.3").jux(rev)
const k = s("bd*4").bank("RolandTR909").gain(1.1)
const kOut = s("bd*4").bank("RolandTR909").gain(saw.range(1,0).slow(20))
const clap = s("~ cp ~ cp").bank("RolandTR909")
const hats = s("hh*8").bank("RolandTR606").gain("[.3 .5]*2")
const hats16 = s("hh*16").bank("RolandTR606").gain(".25 .4").pan(sine.range(.3,.7).fast(3))
const ohh = s("~ oh ~ oh").bank("RolandTR808").gain(.4)
const ride = s("~ ~ rd ~").bank("LinnDrum").gain(.35)
const rims = s("rim(3,8)").bank("OberheimDMX").gain(.4)
const roll = s("sd*16").bank("RolandTR909").gain(saw.range(.05,.9).slow(40))
const toms = s("lt mt ht lt").bank("RolandTR808").gain(.5).sometimesBy(.3, x=>x.fast(2))
const clave = s("cp(3,8)").bank("RolandTR707").gain(.5)
const pad = note("<[c3,eb3,g3] [ab2,c3,eb3] [f2,ab2,c3] [g2,bb2,d3]>").s("gm_pad_warm").slow(2).gain(.5).room(.7).lpf(1300)
const box = note("c5 eb5 g5 c6 g5 eb5 g5 eb5").s("gm_music_box").gain(.4).delay(.35).room(.5)
const piano = note("<[g4 bb4 c5 bb4 g4 eb4 f4 g4] [g4 bb4 c5 d5 c5 bb4 g4 f4]>").s("gm_epiano1").room(.4).delay(.3).gain(.6)
const flauta = note("<[g4 bb4 c5 d5 eb5 d5 c5 bb4] [g4 bb4 c5 f5 eb5 c5 bb4 g4]>").s("gm_pan_flute").room(.5).delay(.3).dfb(.4).gain(.6)
const flautaBreak = note("[g4 bb4 c5 d5 eb5 d5 c5 bb4]").s("gm_pan_flute").room(.5).delay(.35).gain(.6).jux(rev).every(2, x=>x.fast(2))
const flautaOut = note("c5 bb4 g4 eb4 c4 bb3 g3 c3").s("gm_pan_flute").slow(2).room(.85).gain(saw.range(.6,0).slow(20))
const riser = note("c6").s("gm_fx_atmosphere").gain(saw.range(.1,.5).slow(40)).hpf(saw.range(400,6000).slow(40))
const bleep = note("c4 eb4 g4 c5").fast(2).s("square").lpf(sine.range(900,3200).fast(2)).lpq(10).euclidLegato(5,8).gain(.4).delay(.25).room(.3)
const crash = s("cr").bank("RolandTR909").gain(.4).room(.5).slow(4)
arrange(
[32, stack(pad, box, hats.gain(sine.range(0,.35).slow(8)).hpf(7000))],
[48, stack(k, clap, hats, ohh, acid)],
[32, stack(k, clap, hats, ride, piano, acid.lpq(11).distort("1:.35"))],
[40, stack(k, clap, hats16, rims, flauta, acid.lpq(13))],
[40, stack(k, clap, hats16.gain(saw.range(.2,.6).slow(40)), roll, acidBuild, riser, crash)],
[28, stack(k, clap, hats16, clave, toms, acidClimax, flautaBreak, bleep)],
[20, stack(kOut, s("~ cp ~ ~").bank("RolandTR909").gain(saw.range(.7,0).slow(20)),
flautaOut, acid.lpf(saw.range(2000,200).slow(20)).gain(saw.range(.9,0).slow(20)))]
)`,
},
{
name: 'acid largo · azul · Am 128 (808/Linn/RhythmAce)',
code: `// acid azul · La menor · 128 BPM · ~7:30 (hipnótico y profundo)
// intro melódico → drop → beat+kalimba → marimba pegadiza → subida → clímax → descenso
setcpm(128/4)
const acid = note("a1 [a2 a1] c2 a1 e2 a1 [g1 a1] e1").s("sawtooth")
.lpf(sine.range(400,1800).slow(6)).lpq(12).lpenv(4).lpa(.02).lpd(.16).lps(0).distort("0.7:.35").delay(.15)
const acidBuild = acid.lpf(saw.range(400,2800).slow(40)).lpq(14).distort("0.9:.35")
const acidClimax = acid.lpf(sine.range(600,2800).slow(2)).lpq(15).distort("1.1:.3").jux(rev)
const k = s("bd*4").bank("RolandTR808").gain(1.1).lpf(4000)
const kOut = s("bd*4").bank("RolandTR808").lpf(4000).gain(saw.range(1,0).slow(20))
const clap = s("~ cp ~ cp").bank("LinnDrum").gain(.8)
const hats = s("hh*8").bank("RhythmAce").gain(".25 .4")
const hats16 = s("hh*16").bank("RhythmAce").gain(".2 .35").pan(sine.range(.35,.65).fast(2))
const ohh = s("~ oh ~ oh").bank("KorgMinipops").gain(.35)
const ride = s("~ ~ rd ~").bank("LinnDrum").gain(.3)
const perc = s("perc(3,8)").bank("RhythmAce").gain(.4)
const roll = s("sd*16").bank("LinnDrum").gain(saw.range(.05,.8).slow(40))
const toms = s("lt mt ht lt").bank("RolandTR808").gain(.45).sometimesBy(.3, x=>x.fast(2))
const clave = s("cp(3,8)").bank("KorgMinipops").gain(.4)
const pad = note("<[a2,c3,e3] [f2,a2,c3] [d2,f2,a2] [e2,g2,b2]>").s("gm_pad_new_age").slow(2).gain(.5).room(.8).lpf(1400)
const vibra = note("a5 c6 e6 a6 e6 c6 e6 c6").s("gm_vibraphone").gain(.35).delay(.4).room(.6)
const kalimba= note("<[a4 c5 e5 a5 e5 c5 e5 c5] [a4 c5 d5 e5 d5 c5 a4 c5]>").s("gm_kalimba").room(.5).delay(.3).gain(.6)
const marimba= note("<[a4 c5 d5 e5 g5 e5 d5 c5] [a4 c5 e5 g5 a5 g5 e5 d5]>").s("gm_marimba").room(.5).delay(.3).dfb(.4).gain(.6)
const marimbaBreak = note("[a4 c5 d5 e5 g5 e5 d5 c5]").s("gm_marimba").room(.5).delay(.35).gain(.6).jux(rev).every(2, x=>x.fast(2))
const marimbaOut = note("e5 c5 a4 g4 e4 c4 a3 a2").s("gm_marimba").slow(2).room(.85).gain(saw.range(.6,0).slow(20))
const riser = note("a5").s("gm_fx_atmosphere").gain(saw.range(.1,.5).slow(40)).hpf(saw.range(400,6000).slow(40))
const bleep = note("a4 c5 e5 a5").fast(2).s("square").lpf(sine.range(800,2800).fast(2)).lpq(9).euclidLegato(5,8).gain(.38).delay(.3).room(.4)
const crash = s("cr").bank("LinnDrum").gain(.35).room(.6).slow(4)
arrange(
[32, stack(pad, vibra, hats.gain(sine.range(0,.3).slow(8)).hpf(6500))],
[48, stack(k, clap, hats, ohh, acid)],
[32, stack(k, clap, hats, ride, kalimba, acid.lpq(10).distort("0.5:.35"))],
[40, stack(k, clap, hats16, perc, marimba, acid.lpq(12))],
[40, stack(k, clap, hats16.gain(saw.range(.15,.5).slow(40)), roll, acidBuild, riser, crash)],
[28, stack(k, clap, hats16, clave, toms, acidClimax, marimbaBreak, bleep)],
[20, stack(kOut, s("~ cp ~ ~").bank("LinnDrum").gain(saw.range(.65,0).slow(20)),
marimbaOut, acid.lpf(saw.range(1800,180).slow(20)).gain(saw.range(.9,0).slow(20)))]
)`,
},
{
name: 'acid largo · verde · E frigio 134 psy (707/DMX/SP12)',
code: `// acid verde · Mi frigio (psy) · 134 BPM · ~7:10 (bajo rolling a contratiempo)
// intro melódico → drop → beat+koto → sitar pegadizo → subida → clímax → descenso
setcpm(134/4)
const acid = note("[~ e1 e1 e1] [~ e1 e1 e1] [~ c2 c2 c2] [~ d2 d2 d2]").s("sawtooth")
.lpf(sine.range(400,2400).slow(4)).lpq(16).lpenv(5).lpa(.01).lpd(.1).lps(0).distort("1.2:.3")
const acidBuild = acid.lpf(saw.range(400,3400).slow(40)).lpq(17).distort("1.3:.3")
const acidClimax = acid.lpf(sine.range(700,3400).slow(2)).lpq(18).distort("1.5:.25").jux(rev)
const k = s("bd*4").bank("RolandTR707").gain(1)
const kOut = s("bd*4").bank("RolandTR707").gain(saw.range(1,0).slow(20))
const clap = s("~ cp ~ cp").bank("OberheimDMX")
const hats = s("hh*8").bank("EmuSP12").gain(".3 .5")
const hats16 = s("hh*16").bank("EmuSP12").gain(".25 .4").pan(sine.range(.3,.7).fast(3))
const ohh = s("~ oh ~ oh").bank("OberheimDMX").gain(.4)
const ride = s("~ ~ rd ~").bank("OberheimDMX").gain(.3)
const rims = s("rim(5,8)").bank("CasioRZ1").gain(.4)
const roll = s("sd*16").bank("OberheimDMX").gain(saw.range(.05,.9).slow(40))
const toms = s("lt mt ht lt").bank("RolandTR707").gain(.5).sometimesBy(.3, x=>x.fast(2))
const cowbell= s("cb(5,8)").bank("CasioRZ1").gain(.4)
const pad = note("<[e3,g3,b3] [f3,a3,c4] [g3,b3,d4] [e3,g3,b3]>").s("gm_pad_metallic").slow(2).gain(.5).room(.75).lpf(1400)
const box = note("e5 g5 b5 e6 b5 g5 b5 g5").s("gm_music_box").gain(.4).delay(.35).room(.5)
const koto = note("<[e4 g4 a4 b4 a4 g4 a4 g4] [e4 g4 b4 c5 b4 a4 g4 a4]>").s("gm_koto").room(.4).delay(.3).gain(.6)
const sitar = note("<[e4 f4 g4 a4 b4 a4 g4 f4] [e4 g4 b4 c5 b4 a4 g4 e4]>").s("gm_sitar").room(.5).delay(.3).dfb(.4).gain(.6)
const sitarBreak = note("[e4 f4 g4 a4 b4 a4 g4 f4]").s("gm_sitar").room(.5).delay(.35).gain(.6).jux(rev).every(2, x=>x.fast(2))
const sitarOut = note("b4 a4 g4 f4 e4 d4 c4 e3").s("gm_sitar").slow(2).room(.85).gain(saw.range(.6,0).slow(20))
const riser = note("e6").s("gm_fx_atmosphere").gain(saw.range(.1,.5).slow(40)).hpf(saw.range(400,6000).slow(40))
const bleep = note("e4 g4 b4 e5").fast(2).s("square").lpf(sine.range(900,3400).fast(2)).lpq(11).euclidLegato(7,16).gain(.4).delay(.25).room(.3)
const crash = s("cr").bank("OberheimDMX").gain(.4).room(.5).slow(4)
arrange(
[32, stack(pad, box, hats.gain(sine.range(0,.35).slow(8)).hpf(7000))],
[48, stack(k, clap, hats, ohh, acid)],
[32, stack(k, clap, hats, ride, koto, acid.lpq(13).distort("1:.3"))],
[40, stack(k, clap, hats16, rims, sitar, acid.lpq(14))],
[40, stack(k, clap, hats16.gain(saw.range(.2,.6).slow(40)), roll, acidBuild, riser, crash)],
[28, stack(k, clap, hats16, cowbell, toms, acidClimax, sitarBreak, bleep)],
[20, stack(kOut, s("~ cp ~ ~").bank("OberheimDMX").gain(saw.range(.7,0).slow(20)),
sitarOut, acid.lpf(saw.range(2200,180).slow(20)).gain(saw.range(.9,0).slow(20)))]
)`,
},
];
// cada familia vive en su carpeta (la pestaña Projects las muestra plegables)
const enCarpeta = (list, folder) => list.map((p) => ({ ...p, folder }));
export const DEFAULT_PROJECTS = [
...enCarpeta(CLASSICS, 'bases'),
...enCarpeta(CONCEPTS, 'acid conceptual'),
...enCarpeta(DETALLE, 'detalle'),
...enCarpeta(ESTILOS, 'techno estilos'),
...enCarpeta(LARGOS, 'acid largo'),
].map((p, i) => ({
...p,
created_at: i + 1,
}));

View file

@ -48,7 +48,8 @@ export const defaultSettings = {
isTabIndentationEnabled: false,
isMultiCursorEnabled: false,
isBlockBasedEvalEnabled: false,
theme: 'strudelTheme',
theme: 'neonMadrid',
language: 'es',
fontFamily: 'monospace',
fontSize: 18,
latestCode: '',