chore: refresco desde el monorepo (pin tawasuyu.git v0.3.0)
Trae los README y LEEME de todos los crates del dominio, que ahora existen para los 1.096 crates del workspace.
Delta semántico · 714
02_ruway/takiy/takiy-acordes/src/banco.rs
- nueva struct Banco pub
- nueva struct BancoGuardado pub
- nueva fn agregar pub
- nueva fn con_mut pub
- nueva fn con_propios pub
- nueva fn de_fabrica pub
- nueva fn default
- nueva fn editar pub
- nueva fn editar_un_fijo_lo_copia_en_vez_de_romperlo
- nueva fn el_indice_viejo_no_deja_al_render_sin_patron
- nueva fn es_fijo pub
- nueva fn fijos pub
- nueva fn get pub
- nueva fn get_o_primero pub
- nueva fn guardado pub
- nueva fn is_empty pub
- nueva fn len pub
- nueva fn los_fijos_no_se_borran_ni_se_mutan
- nueva fn nombre_libre
- nueva fn nuevo pub
- nueva fn p
- nueva fn patrones pub
- nueva fn propios pub
- nueva fn quitar pub
- nueva fn s
- nueva fn solo_los_propios_van_a_disco_y_vuelven
- nueva mod tests
- nueva fn todos_los_de_fabrica_suenan_y_tienen_nombre_unico
02_ruway/takiy/takiy-acordes/src/cancion.rs
- nueva struct Cancion pub
- nueva fn absorber_propios pub
- nueva fn banco pub
- nueva fn de_arranque pub
- nueva fn default
- nueva fn la_cancion_de_arranque_ya_suena
- nueva fn score pub
- nueva mod tests
- nueva fn un_patron_propio_sobrevive_el_viaje_a_disco
02_ruway/takiy/takiy-acordes/src/lib.rs
02_ruway/takiy/takiy-acordes/src/paleta.rs
- nueva def CUALIDADES pub
- nueva struct Paleta pub
- nueva def RAICES pub
- nueva def ROMANOS
- nueva enum TipoTonalidad pub
- nueva fn con_septimas_los_grados_se_colorean
- nueva fn default
- nueva fn diatonico_distingue_al_prestado
- nueva fn el_grado_se_reconoce_al_volver
- nueva fn es_diatonico pub
- nueva fn escala pub
- nueva fn escala pub
- nueva fn etiqueta pub
- nueva fn grado pub
- nueva fn grado_de pub
- nueva fn grados pub
- nueva fn la_matriz_cubre_todas_las_combinaciones
- nueva fn los_grados_de_do_mayor_son_los_del_cancionero
- nueva fn los_grados_de_la_menor_comparten_notas_con_do_mayor
- nueva fn matriz pub
- nueva fn nombre pub
- nueva fn nueva pub
- nueva fn romano pub
- nueva mod tests
02_ruway/takiy/takiy-acordes/src/patron.rs
- nueva enum Direccion pub
- nueva enum Golpe pub
- nueva struct Paso pub
- nueva struct Patron pub
- nueva fn ataques pub
- nueva fn ciclar pub
- nueva fn de pub
- nueva fn default
- nueva fn desde pub
- nueva fn el_ciclo_de_golpes_vuelve_al_silencio
- nueva fn invertida pub
- nueva fn limpiar pub
- nueva fn nuevo pub
- nueva fn paso pub
- nueva fn pasos pub
- nueva fn pasos_por_compas pub
- nueva fn redimensionar pub
- nueva fn redimensionar_conserva_lo_editado
- nueva fn rotar pub
- nueva fn rotar_es_circular_y_no_pierde_ataques
- nueva fn set_golpe pub
- nueva fn set_largo pub
- nueva fn set_velocidad pub
- nueva fn siguiente pub
- nueva fn simbolo pub
- nueva fn simbolo pub
- nueva fn suena pub
- nueva mod tests
- nueva fn un_patron_vacio_nunca_tiene_cero_pasos
- nueva fn vacio pub
02_ruway/takiy/takiy-acordes/src/progresion.rs
- nueva def CADENCIAS pub
- nueva struct Progresion pub
- nueva struct Ranura pub
- nueva fn agregar pub
- nueva fn cadencia pub
- nueva fn compases pub
- nueva fn desde pub
- nueva fn desde_grados pub
- nueva fn do_mayor
- nueva fn duplicar pub
- nueva fn duplicar_y_quitar_son_inversas
- nueva fn inicios pub
- nueva fn insertar pub
- nueva fn is_empty pub
- nueva fn la_cadencia_mas_usada_del_mundo
- nueva fn len pub
- nueva fn los_inicios_respetan_largos_distintos
- nueva fn mover pub
- nueva fn mover_reordena_sin_perder_ranuras
- nueva fn nombre pub
- nueva fn nueva pub
- nueva fn nueva pub
- nueva fn quitar pub
- nueva fn ranura pub
- nueva fn ranura_mut pub
- nueva fn ranuras pub
- nueva fn repetir pub
- nueva fn repetir_tilea_la_cadena
- nueva mod tests
- nueva fn todas_las_cadencias_del_catalogo_dan_acordes
- nueva fn transponer pub
- nueva fn transponer_cinco_semitonos_lleva_do_a_fa
02_ruway/takiy/takiy-acordes/src/quintas.rs
- nueva def ORDEN pub
- nueva struct Prestado pub
- nueva fn desplazar
- nueva fn destinos pub
- nueva fn distancia pub
- nueva fn el_orden_del_circulo_es_por_quintas
- nueva fn la_distancia_es_simetrica_y_corta
- nueva fn la_relativa_comparte_las_siete_notas
- nueva fn las_doce_estan_una_sola_vez
- nueva fn los_prestados_son_ajenos_a_la_tonalidad_pero_no_al_oido
- nueva fn los_tres_destinos_son_distintos_del_origen
- nueva fn modular pub
- nueva fn modular_un_paso_cambia_una_sola_nota
- nueva fn mover pub
- nueva fn posicion pub
- nueva fn prestados pub
- nueva fn relativa pub
- nueva mod tests
02_ruway/takiy/takiy-acordes/src/render.rs
- nueva struct Ajustes pub
- nueva fn ajustes
- nueva fn cada_acorde_cae_en_su_compas
- nueva fn default
- nueva fn do_mayor
- nueva fn el_bajo_y_la_bateria_entran_como_pistas_aparte
- nueva fn el_rasgueo_abre_las_voces_en_el_tiempo
- nueva fn el_render_es_determinista
- nueva fn el_swing_atrasa_solo_los_pasos_impares
- nueva fn en_vals_no_hay_bateria_de_cuatro_cuartos
- nueva fn la_inversion_sube_la_voz_grave_una_octava
- nueva fn ninguna_nota_pisa_el_acorde_siguiente
- nueva fn render pub
- nueva mod tests
- nueva fn tocar_bajo
- nueva fn tocar_ranura
- nueva fn una_progresion_vacia_da_un_score_sin_notas
- nueva fn una_ranura_de_medio_compas_toca_media_grilla
- nueva fn voces_de pub
02_ruway/takiy/takiy-ambiente/examples/dia.rs
- cambió por dentro fn main
02_ruway/takiy/takiy-ambiente/src/lib.rs
- cambió de firma struct AmbientSignals pub
- cambió por dentro fn clave_musical pub
- cambió por dentro fn default
- nueva fn otra_hebra_es_otra_melodia_del_mismo_color
- nueva fn subir_la_hebra_pide_regenerar
02_ruway/takiy/takiy-app-llimphi/examples/pantallazo_takiy.rs
- nueva mod acordes
- nueva fn acordes_view
- nueva mod inicio
- cambió por dentro fn main
- cambió por dentro fn model_demo
02_ruway/takiy/takiy-app-llimphi/src/acordes.rs
- nueva def CORTE_CUALIDADES
- nueva def Estado
- nueva def MsgAcordes
- nueva def Sonar
- nueva def aplicar
- nueva fn arranca_con_una_cancion_que_ya_suena
- nueva def blip_de_ranura
- nueva def body
- nueva fn boton_acorde
- nueva fn caja
- nueva fn cambiar_de_tonalidad_cambia_la_paleta_pero_no_la_cinta
- nueva def cargar
- nueva fn celda_paso
- nueva fn correr
- nueva fn default
- nueva fn editar_la_grilla_no_toca_el_patron_de_fabrica
- nueva fn el_patron_vacio_es_propio_y_no_suena_hasta_que_se_edita
- nueva fn el_ritmo_de_una_ranura_se_cicla_y_vuelve_al_de_la_cancion
- nueva fn el_score_respeta_los_ajustes_de_la_pantalla
- nueva fn etiqueta_cualidad
- nueva fn fila_alta
- nueva fn fmt_compases
- nueva fn grado_romano
- nueva def guardar
- nueva fn hacer_mio
- nueva fn la_cancion_y_los_ritmos_propios_sobreviven_el_cierre
- nueva fn la_cualidad_elegida_se_marca_en_una_sola_fila
- nueva fn paleta
- nueva def patron_nuevo
- nueva fn quitar_la_ultima_ranura_no_deja_la_seleccion_colgada
- nueva fn ranura
- nueva def resumen
- nueva def ruta
- nueva def score
- nueva fn sel_en_fila
- nueva mod tests
- nueva fn tocar_un_grado_lo_agrega_y_lo_hace_sonar
- nueva fn transponer_mueve_la_cinta_y_la_tonalidad_juntas
- nueva fn un_archivo_ilegible_no_traba_la_pantalla
- nueva fn una_cadencia_reemplaza_la_cinta_entera
02_ruway/takiy/takiy-app-llimphi/src/appmodel.rs
02_ruway/takiy/takiy-app-llimphi/src/chrome.rs
- cambió por dentro fn automacion_rows
- cambió por dentro fn hrow
- cambió por dentro fn instrumento_rows
- cambió por dentro fn mixer_rows
- cambió por dentro def right_sidebar
- cambió por dentro fn row_h
- cambió por dentro fn section_label
- cambió por dentro fn switch_row
- cambió por dentro fn tonalidad_rows
- cambió por dentro def toolbar_bar
02_ruway/takiy/takiy-app-llimphi/src/clipstore.rs
- cambió por dentro fn read_f32_wav
02_ruway/takiy/takiy-app-llimphi/src/demo.rs
- cambió por dentro fn demo_score pub
02_ruway/takiy/takiy-app-llimphi/src/escucha.rs
02_ruway/takiy/takiy-app-llimphi/src/genesis.rs
- cambió de firma def MOODS
- cambió de firma def STYLES
- cambió por dentro fn buttons
- cambió por dentro def chip
- cambió por dentro def dialog_row
- cambió por dentro def labeled
- cambió por dentro fn mini
- cambió por dentro def modal
- cambió por dentro def overlay
- cambió por dentro def pill_button
- cambió por dentro def row_h
- cambió por dentro def stepper
02_ruway/takiy/takiy-app-llimphi/src/inicio.rs
- nueva def CAMINOS
- nueva def InicioForm
- nueva def Paso
- nueva fn acordes
- nueva fn botonera
- nueva fn camino_card
- nueva fn chips
- nueva fn default
- nueva fn encabezado
- nueva fn fila
- nueva fn hay_cinco_caminos_y_ninguno_esta_vacio
- nueva fn los_catalogos_que_pinta_el_wizard_no_estan_vacios
- nueva fn los_tres_pasos_arman_su_vista
- nueva fn menu
- nueva fn tarareo
- nueva mod tests
- nueva def view
- nueva fn vu
02_ruway/takiy/takiy-app-llimphi/src/instr.rs
02_ruway/takiy/takiy-app-llimphi/src/llmhud.rs
- cambió por dentro def overlay
02_ruway/takiy/takiy-app-llimphi/src/main.rs
- nueva mod acordes
- cambió por dentro fn app_menu
- cambió por dentro fn context_menu_for_selection
- cambió por dentro fn handle_menu_command
- nueva mod inicio
- cambió por dentro fn init
- cambió por dentro fn on_key
- cambió por dentro fn scene_key
- cambió por dentro fn view
02_ruway/takiy/takiy-app-llimphi/src/msg.rs
- cambió de firma def Msg
02_ruway/takiy/takiy-app-llimphi/src/overview.rs
- cambió por dentro fn add_track_row
- cambió por dentro def body
- cambió por dentro fn hrow
- cambió por dentro fn lane_row
02_ruway/takiy/takiy-app-llimphi/src/proyecto.rs
- cambió por dentro fn commit_row
- cambió por dentro fn left_body
- cambió por dentro def left_sidebar
- cambió por dentro fn row_h
- cambió por dentro fn track_row
- cambió por dentro fn version_graph
02_ruway/takiy/takiy-app-llimphi/src/record.rs
- cambió por dentro fn hud_bar
02_ruway/takiy/takiy-app-llimphi/src/secedit.rs
02_ruway/takiy/takiy-app-llimphi/src/update.rs
- cambió de firma fn abrir_pieza_generada
- cambió por dentro def actualizar
- cambió por dentro fn aplicar_continuacion
- nueva fn armar_desde_tarareo
- nueva fn audition_blip
- cambió por dentro fn audition_pitch
- cambió por dentro fn correr_escucha
- cambió por dentro fn fuente_activa
- nueva fn guardar_acordes
- cambió por dentro fn start_recording
- nueva fn tocar_progresion
02_ruway/takiy/takiy-app-llimphi/src/voz.rs
- cambió por dentro def overlay
02_ruway/takiy/takiy-app-llimphi/src/waveedit.rs
02_ruway/takiy/takiy-app-llimphi/src/wavimport.rs
02_ruway/takiy/takiy-core/src/chord.rs
- cambió de firma enum ChordQuality pub
- cambió por dentro fn intervals pub
- nueva fn la_novena_suena_una_octava_arriba
- cambió por dentro fn suffix pub
- nueva fn sus_y_potencia_no_tienen_tercera
02_ruway/takiy/takiy-escucha/src/armar.rs
- nueva struct Armado pub
- nueva struct OpcionesArmar pub
- nueva fn armar pub
- nueva fn de_una_melodia_sale_una_cancion_con_forma
- nueva fn default
- nueva fn es_menor
- nueva fn la_melodia_de_la_cancion_es_la_que_se_canto
- nueva fn los_acordes_armonizados_pertenecen_a_la_tonalidad
- nueva fn misma_semilla_misma_cancion
- nueva fn resumen pub
- nueva fn tarareo
- nueva mod tests
- nueva fn un_tarareo_de_verdad_llega_a_cancion
02_ruway/takiy/takiy-escucha/src/chords.rs
- nueva fn armonizar_diatonico pub
02_ruway/takiy/takiy-escucha/src/lib.rs
- nueva mod armar pub
02_ruway/takiy/takiy-genesis/src/arrange.rs
- nueva struct PlanArreglo pub
- nueva fn compases_de
- cambió por dentro fn compose pub
- nueva fn compose_con pub
- nueva fn el_plan_vacio_no_cambia_una_nota
- nueva fn la_melodia_dada_reemplaza_a_la_inventada_y_no_mueve_el_resto
- nueva fn tile_notes
02_ruway/takiy/takiy-karaoke-llimphi/src/lib.rs
- nueva def AGUDOS_HZ
- nueva def BAJOS_HZ
- nueva def CUENTA_PULSOS
- nueva def CUENTA_SEG_POR_PULSO
- nueva enum Estado pub
- nueva def MAX_RETARDO_MS
- nueva struct Sesion pub
- nueva fn base_a_tocar pub
- nueva fn cargar pub
- nueva fn cargar_letra pub
- nueva fn cerrar_toma pub
- nueva fn corte pub
- nueva fn corte_marca_la_silaba_de_ahora
- nueva fn corte_no_parte_un_caracter_multibyte
- nueva fn corte_sin_silabas_reparte_por_caracteres
- nueva fn cuenta_muestras pub
- nueva fn cuenta_s pub
- nueva fn duracion_s pub
- nueva fn guardar_mezcla pub
- nueva fn ms
- nueva fn pcm_a_tocar pub
- nueva fn reloj_cancion pub
- nueva mod tests
- nueva fn tiene_estereo pub
- nueva fn veredicto pub
- nueva fn veredicto_dice_para_donde_desafinas
02_ruway/takiy/takiy-karaoke-llimphi/src/main.rs
02_ruway/takiy/takiy-karaoke-llimphi/tests/sala.rs
- nueva def RETARDO_MS
- nueva def SR
- nueva fn acompanamiento
- nueva fn desafinar_baja_el_puntaje
- nueva fn dir_temporal
- nueva fn el_barrido_avanza_con_el_reloj
- nueva fn escribir_letra
- nueva fn escribir_tema
- nueva fn nota
- nueva fn seno
- nueva fn sin_letra_la_sala_igual_funciona
- nueva fn tema
- nueva fn una_toma_de_punta_a_punta
- nueva fn voz_original
02_ruway/takiy/takiy-karaoke/src/lib.rs
- nueva def BPM
- nueva def CENTS_CERO
- nueva def CLARIDAD_MIN
- nueva def HOP
- nueva def HOP_ENV
- nueva struct NotaCantada pub
- nueva struct NotaEsperada pub
- nueva struct Puntaje pub
- nueva def SR
- nueva def VECINDAD_MS
- nueva def WIN
- nueva fn centro pub
- nueva fn centro_deja_la_voz_y_saca_lo_lateral
- nueva fn centro_pasaaltos_saca_el_bajo
- nueva fn detectar_offset pub
- nueva fn detectar_offset_mide_el_retardo_real
- nueva fn detectar_offset_no_inventa_con_silencio
- nueva fn detectar_offset_no_mide_sobre_un_tono_plano
- nueva fn envolvente
- nueva fn estereo
- nueva fn melodia_de_audio pub
- nueva fn melodia_de_audio_transcribe_lo_que_suena
- nueva fn melodia_de_notas pub
- nueva fn melodia_de_notas_traduce_pulsos_a_segundos
- nueva fn mezclar pub
- nueva fn mezclar_compensa_el_retardo
- nueva fn mezclar_estira_la_salida_si_la_voz_sobra
- nueva fn new pub
- nueva fn pico
- nueva fn puntuar pub
- nueva fn puntuar_da_cero_al_que_no_canta
- nueva fn puntuar_no_castiga_cantar_una_octava_abajo
- nueva fn puntuar_premia_la_nota_justa_y_castiga_la_desafinada
- nueva fn puntuar_respeta_el_offset
- nueva fn seno
- nueva fn sin_voz pub
- nueva fn sin_voz_cancela_el_centro_y_deja_los_lados
- nueva fn sin_voz_reinyecta_los_graves_del_centro
- nueva mod tests
02_ruway/takiy/takiy-tanda/src/biblioteca.rs
- nueva struct Biblioteca pub
- nueva fn activa pub
- nueva fn agregar pub
- nueva fn agregar_no_pisa_y_renombrar_sigue_a_la_activa
- nueva fn cargar pub
- nueva fn de_fabrica pub
- nueva fn default
- nueva fn duplicar pub
- nueva fn el_maestro_apagado_deja_todo_mudo
- nueva fn el_volumen_maestro_escala_pero_no_sube
- nueva fn la_activa_siempre_es_valida
- nueva fn load_or_seed pub
- nueva fn mtime pub
- nueva fn nombre_unico
- nueva fn nombres pub
- nueva fn path pub
- nueva fn pluma
- nueva fn quitar pub
- nueva fn quitar_nunca_deja_la_biblioteca_vacia
- nueva fn renombrar pub
- nueva fn restaurar_preset pub
- nueva fn sanear pub
- nueva fn save pub
- nueva fn set_activa pub
- nueva fn tanda_efectiva pub
- nueva mod tests
- nueva fn uno
- nueva fn verdadero
02_ruway/takiy/takiy-tanda/src/bin/tanda.rs
02_ruway/takiy/takiy-tanda/src/cosecha.rs
- nueva fn al_dia pub
- nueva fn archivo pub
- nueva fn carpeta pub
- nueva fn cosechar pub
- nueva fn cosechar_y_volver_a_leer_da_el_mismo_aviso
- nueva fn el_nombre_de_la_tanda_no_se_escapa_de_su_carpeta
- nueva fn el_wav_da_la_vuelta_completa
- nueva fn leer pub
- nueva fn leer_wav
- nueva fn leer_wav_de pub
- nueva fn limpiar pub
- nueva fn raiz pub
- nueva mod tests
- nueva fn un_wav_roto_no_rompe_nada
02_ruway/takiy/takiy-tanda/src/evento.rs
- nueva def EVENTOS pub
- nueva enum Evento pub
- nueva struct Gesto pub
- nueva struct Nota pub
- nueva fn es_de_fondo pub
- nueva fn from_id pub
- nueva fn gesto pub
- nueva fn id pub
- nueva fn ids_estables_y_biyectivos
- nueva fn label pub
- nueva fn largo pub
- nueva fn lo_cotidiano_suena_mas_bajo_que_lo_excepcional
- nueva fn los_gestos_no_son_la_misma_frase_transportada
- nueva fn los_gestos_son_breves_y_ordenados
- nueva fn new pub
- nueva fn no_hay_ids_ni_rotulos_repetidos
- nueva fn segunda pub
- nueva mod tests
02_ruway/takiy/takiy-tanda/src/lib.rs
02_ruway/takiy/takiy-tanda/src/muestras.rs
- nueva def BPM
- nueva struct Muestras pub
- nueva def RUTAS_HABITUALES
- nueva fn a_mono
- nueva fn abrir pub
- nueva fn cada_timbre_tiene_su_instrumento_y_no_se_repiten
- nueva fn del_sistema pub
- nueva fn la_conversion_a_midi_es_la_estandar
- nueva fn pitch_de_hz
- nueva fn programa_gm pub
- nueva fn recortar_silencio_final
- nueva fn render pub
- nueva fn ruta pub
- nueva fn ruta_sf2 pub
- nueva fn sample_rate pub
- nueva fn si_hay_soundfont_la_tanda_suena_y_respeta_el_nivel
- nueva mod tests
02_ruway/takiy/takiy-tanda/src/paleta.rs
- nueva def AIRE
- nueva enum Articulacion pub
- nueva def CAMPANA
- nueva def CHARANGO
- nueva def CRISTAL
- nueva def CUERDA
- nueva enum Escala pub
- nueva def MADERA
- nueva enum Motivo pub
- nueva def PIANO
- nueva struct Paleta pub
- nueva struct Parcial pub
- nueva def QUENA
- nueva def TODAS pub
- nueva def TODOS pub
- nueva def TODOS pub
- nueva enum Timbre pub
- nueva def VIOLIN
- nueva enum Voz pub
- nueva fn aire_def
- nueva fn articulacion pub
- nueva fn ataque pub
- nueva fn brillo_def
- nueva fn cola pub
- nueva fn cola_lenta pub
- nueva fn cuerpo_def
- nueva fn default
- nueva fn detune_cents pub
- nueva fn el_motivo_imanta_sin_perder_la_octava
- nueva fn el_motivo_no_saca_a_la_nota_de_su_octava
- nueva fn escala_def
- nueva fn frecuencia pub
- nueva fn from_id pub
- nueva fn from_id pub
- nueva fn from_id pub
- nueva fn ganancia_def
- nueva fn grados pub
- nueva fn grados_por_octava pub
- nueva fn id pub
- nueva fn id pub
- nueva fn id pub
- nueva fn ids_de_timbre_y_escala_vuelven
- nueva fn imantar pub
- nueva fn imantar_frase pub
- nueva fn imantar_una_frase_no_aplana_el_contorno
- nueva fn la_octava_duplica_la_frecuencia
- nueva fn label pub
- nueva fn label pub
- nueva fn label pub
- nueva fn los_grados_envuelven_por_octava
- nueva fn motivo_def
- nueva fn ningun_timbre_vibra_como_una_sirena
- nueva fn p
- nueva fn parciales pub
- nueva fn pua pub
- nueva fn raiz_def
- nueva fn sanear pub
- nueva fn sanear_recorta_lo_absurdo
- nueva fn semitonos pub
- nueva fn semitonos_de_grado pub
- nueva fn soplo pub
- nueva fn suavidad_def
- nueva mod tests
- nueva fn timbre_def
- nueva fn tremolo pub
- nueva fn vecino
- nueva fn vibrato pub
- nueva fn vibrato_hz pub
- nueva fn voz pub
- nueva fn voz2_def
02_ruway/takiy/takiy-tanda/src/prosa.rs
- nueva enum Efecto
- nueva def LEXICO
- nueva enum Perilla
- nueva struct Rng
- nueva def SEGURAS
- nueva def VACIAS
- nueva fn centrado
- nueva fn cerca_apaga_el_eco
- nueva fn cerca_y_lejos_mueven_el_aire_en_direcciones_opuestas
- nueva fn coincide
- nueva fn corto_y_largo_mueven_el_cuerpo
- nueva fn desde_prosa pub
- nueva fn el_lexico_manda_sobre_el_azar
- nueva fn el_motivo_sale_de_la_frase
- nueva fn el_nombre_sale_de_la_frase
- nueva fn el_orden_de_la_frase_reparte_las_voces
- nueva fn frases_distintas_dan_tandas_distintas
- nueva fn hash_semilla
- nueva fn hasta
- nueva fn la_altura_andina_sale_entera_de_una_frase
- nueva fn la_ganancia_nunca_sube_por_azar
- nueva fn la_misma_frase_da_la_misma_tanda
- nueva fn la_semilla_varia_sin_traicionar_la_frase
- nueva fn las_tildes_y_mayusculas_no_cambian_nada
- nueva fn new
- nueva fn next
- nueva fn nombre_desde
- nueva fn pedir_un_solo_instrumento_da_una_tanda_a_solo
- nueva fn sin_instrumento_nombrado_el_azar_arma_un_duo_de_verdad
- nueva fn sin_tilde
- nueva mod tests
- nueva fn tokenizar
- nueva fn una_frase_sin_palabras_conocidas_sigue_dando_algo_sano
- nueva fn unidad
02_ruway/takiy/takiy-tanda/src/render.rs
- nueva def DURACION_MAXIMA pub
- nueva def ECO_SEGUNDOS
- nueva def FADE_FINAL
- nueva def FADE_RECORTADO
- nueva def N
- nueva struct PasaBanda
- nueva def RELEASE_ARCO
- nueva struct Ruido
- nueva def SR
- nueva def acabado
- nueva fn arranca_y_termina_en_silencio
- nueva fn cruces_por_segundo
- nueva fn crudo
- nueva def eco_de
- nueva fn el_batido_del_charango_no_es_una_sola_cuerda
- nueva fn el_charango_no_es_un_piano
- nueva fn el_eco_deja_sonar_la_distancia_despues_del_gesto
- nueva fn el_eco_no_cuelga_los_avisos_de_fondo
- nueva fn el_evento_apagado_no_produce_audio
- nueva fn el_fondo_suena_mas_bajo_que_el_error
- nueva fn el_render_es_determinista
- nueva fn el_vibrato_no_es_una_sirena
- nueva fn entrada_vib
- nueva def fade_final
- nueva fn hz_del_tramo
- nueva fn la_cola_recortada_se_cierra_con_calma
- nueva fn la_quena_no_es_un_violin
- nueva fn la_quena_sopla_de_verdad
- nueva fn mas_cuerpo_es_mas_largo
- nueva fn ningun_evento_clipea_ni_se_pasa_de_largo
- nueva fn normalizar_a
- nueva fn nuevo
- nueva fn paso
- nueva fn periodicidad
- nueva fn render pub
- nueva fn render_muestra pub
- nueva fn sembrado
- nueva fn siguiente
- nueva mod tests
- nueva fn todos_los_timbres_suenan_y_respetan_el_techo
02_ruway/takiy/takiy-tanda/src/reproductor.rs
- nueva def ANTIRREBOTE
- nueva def CHEQUEO_MTIME
- nueva struct Estado
- nueva enum Orden
- nueva def REINTENTO_DEVICE
- nueva struct Reproductor pub
- nueva fn correr
- nueva fn nuevo
- nueva fn player
- nueva fn probar pub
- nueva fn probar
- nueva fn quizas_recargar
- nueva fn recargar pub
- nueva fn recargar
- nueva fn sonar pub
- nueva fn sonar
- nueva fn spawn pub
02_ruway/takiy/takiy-tanda/src/tanda.rs
- nueva struct Ajuste pub
- nueva def PRESETS pub
- nueva def PRESET_DEFECTO pub
- nueva struct Tanda pub
- nueva fn activo pub
- nueva fn ajuste pub
- nueva fn ajuste_de_fabrica pub
- nueva fn default
- nueva fn default
- nueva fn desde_paleta pub
- nueva fn el_fondo_viene_apagado_de_fabrica
- nueva fn el_volumen_del_usuario_manda_pero_no_revienta
- nueva fn pico_objetivo pub
- nueva fn preset pub
- nueva fn resumen pub
- nueva fn round_trip_ron
- nueva fn sanear pub
- nueva fn sanear_tira_los_eventos_inventados
- nueva fn set_activo pub
- nueva fn set_volumen pub
- nueva fn silencio_es_silencio
- nueva mod tests
- nueva fn todos_los_presets_resuelven
132 archivos · +13005 −992
- 02_ruway/takiy/LEEME.md +4 −1
- 02_ruway/takiy/README.md +4 −1
- 02_ruway/takiy/takiy-acordes/Cargo.toml +16 −0
- 02_ruway/takiy/takiy-acordes/LEEME.md +23 −0
- 02_ruway/takiy/takiy-acordes/README.md +21 −0
- 02_ruway/takiy/takiy-acordes/src/banco.rs +332 −0
- 02_ruway/takiy/takiy-acordes/src/cancion.rs +97 −0
- 02_ruway/takiy/takiy-acordes/src/lib.rs +49 −0
- 02_ruway/takiy/takiy-acordes/src/paleta.rs +234 −0
- 02_ruway/takiy/takiy-acordes/src/patron.rs +258 −0
- 02_ruway/takiy/takiy-acordes/src/progresion.rs +246 −0
- 02_ruway/takiy/takiy-acordes/src/quintas.rs +284 −0
- 02_ruway/takiy/takiy-acordes/src/render.rs +394 −0
- 02_ruway/takiy/takiy-ambiente/examples/dia.rs +4 −0
- 02_ruway/takiy/takiy-ambiente/src/lib.rs +47 −0
- 02_ruway/takiy/takiy-app-llimphi/Cargo.toml +5 −2
- 02_ruway/takiy/takiy-app-llimphi/examples/pantallazo_takiy.rs +49 −3
- 02_ruway/takiy/takiy-app-llimphi/src/acordes.rs +912 −0
- 02_ruway/takiy/takiy-app-llimphi/src/appmodel.rs +12 −0
- 02_ruway/takiy/takiy-app-llimphi/src/chrome.rs +29 −22
- 02_ruway/takiy/takiy-app-llimphi/src/clipstore.rs +4 −3
- 02_ruway/takiy/takiy-app-llimphi/src/demo.rs +2 −1
- 02_ruway/takiy/takiy-app-llimphi/src/escucha.rs +8 −7
- 02_ruway/takiy/takiy-app-llimphi/src/genesis.rs +22 −21
- 02_ruway/takiy/takiy-app-llimphi/src/inicio.rs +538 −0
- 02_ruway/takiy/takiy-app-llimphi/src/instr.rs +7 −6
- 02_ruway/takiy/takiy-app-llimphi/src/llmhud.rs +3 −3
- 02_ruway/takiy/takiy-app-llimphi/src/main.rs +122 −26
- 02_ruway/takiy/takiy-app-llimphi/src/msg.rs +33 −0
- 02_ruway/takiy/takiy-app-llimphi/src/overview.rs +9 −8
- 02_ruway/takiy/takiy-app-llimphi/src/proyecto.rs +12 −11
- 02_ruway/takiy/takiy-app-llimphi/src/record.rs +2 −2
- 02_ruway/takiy/takiy-app-llimphi/src/secedit.rs +5 −4
- 02_ruway/takiy/takiy-app-llimphi/src/update.rs +258 −37
- 02_ruway/takiy/takiy-app-llimphi/src/voz.rs +5 −5
- 02_ruway/takiy/takiy-app-llimphi/src/waveedit.rs +4 −3
- 02_ruway/takiy/takiy-app-llimphi/src/wavimport.rs +3 −3
- 02_ruway/takiy/takiy-core/src/chord.rs +53 −0
- 02_ruway/takiy/takiy-escucha/LEEME.md +27 −1
- 02_ruway/takiy/takiy-escucha/README.md +8 −1
- 02_ruway/takiy/takiy-escucha/src/armar.rs +325 −0
- 02_ruway/takiy/takiy-escucha/src/chords.rs +58 −0
- 02_ruway/takiy/takiy-escucha/src/lib.rs +8 −1
- 02_ruway/takiy/takiy-genesis/src/arrange.rs +124 −4
- 02_ruway/takiy/takiy-genesis/src/lib.rs +1 −1
- 02_ruway/takiy/takiy-karaoke-llimphi/Cargo.toml +29 −0
- 02_ruway/takiy/takiy-karaoke-llimphi/LEEME.md +24 −0
- 02_ruway/takiy/takiy-karaoke-llimphi/README.md +23 −0
- 02_ruway/takiy/takiy-karaoke-llimphi/src/lib.rs +389 −0
- 02_ruway/takiy/takiy-karaoke-llimphi/src/main.rs +458 −0
- 02_ruway/takiy/takiy-karaoke-llimphi/tests/sala.rs +236 −0
- 02_ruway/takiy/takiy-karaoke/Cargo.toml +9 −0
- 02_ruway/takiy/takiy-karaoke/LEEME.md +19 −0
- 02_ruway/takiy/takiy-karaoke/README.md +18 −0
- 02_ruway/takiy/takiy-karaoke/src/lib.rs +636 −0
- 02_ruway/takiy/takiy-tanda/Cargo.toml +39 −0
- 02_ruway/takiy/takiy-tanda/LEEME.md +223 −0
- 02_ruway/takiy/takiy-tanda/README.md +26 −0
- 02_ruway/takiy/takiy-tanda/examples/tanda_wav.rs +183 −0
- 02_ruway/takiy/takiy-tanda/muestras/LEEME.md +31 −0
- 02_ruway/takiy/takiy-tanda/muestras/andina-seguida.opus +— −—
- 02_ruway/takiy/takiy-tanda/muestras/andina/advertencia.opus +— −—
- 02_ruway/takiy/takiy-tanda/muestras/andina/bateria.opus +— −—
- 02_ruway/takiy/takiy-tanda/muestras/andina/bienvenida.opus +— −—
- 02_ruway/takiy/takiy-tanda/muestras/andina/bloqueo.opus +— −—
- 02_ruway/takiy/takiy-tanda/muestras/andina/borrado.opus +— −—
- 02_ruway/takiy/takiy-tanda/muestras/andina/captura.opus +— −—
- 02_ruway/takiy/takiy-tanda/muestras/andina/completado.opus +— −—
- 02_ruway/takiy/takiy-tanda/muestras/andina/conecta.opus +— −—
- 02_ruway/takiy/takiy-tanda/muestras/andina/desbloqueo.opus +— −—
- 02_ruway/takiy/takiy-tanda/muestras/andina/desconecta.opus +— −—
- 02_ruway/takiy/takiy-tanda/muestras/andina/despedida.opus +— −—
- 02_ruway/takiy/takiy-tanda/muestras/andina/error.opus +— −—
- 02_ruway/takiy/takiy-tanda/muestras/andina/escritorio.opus +— −—
- 02_ruway/takiy/takiy-tanda/muestras/andina/mensaje.opus +— −—
- 02_ruway/takiy/takiy-tanda/muestras/andina/notificacion.opus +— −—
- 02_ruway/takiy/takiy-tanda/muestras/andina/ventana-abre.opus +— −—
- 02_ruway/takiy/takiy-tanda/muestras/andina/ventana-cierra.opus +— −—
- 02_ruway/takiy/takiy-tanda/muestras/andina/volumen.opus +— −—
- 02_ruway/takiy/takiy-tanda/muestras/partisana-seguida.opus +— −—
- 02_ruway/takiy/takiy-tanda/muestras/partisana/advertencia.opus +— −—
- 02_ruway/takiy/takiy-tanda/muestras/partisana/bateria.opus +— −—
- 02_ruway/takiy/takiy-tanda/muestras/partisana/bienvenida.opus +— −—
- 02_ruway/takiy/takiy-tanda/muestras/partisana/bloqueo.opus +— −—
- 02_ruway/takiy/takiy-tanda/muestras/partisana/borrado.opus +— −—
- 02_ruway/takiy/takiy-tanda/muestras/partisana/captura.opus +— −—
- 02_ruway/takiy/takiy-tanda/muestras/partisana/completado.opus +— −—
- 02_ruway/takiy/takiy-tanda/muestras/partisana/conecta.opus +— −—
- 02_ruway/takiy/takiy-tanda/muestras/partisana/desbloqueo.opus +— −—
- 02_ruway/takiy/takiy-tanda/muestras/partisana/desconecta.opus +— −—
- 02_ruway/takiy/takiy-tanda/muestras/partisana/despedida.opus +— −—
- 02_ruway/takiy/takiy-tanda/muestras/partisana/error.opus +— −—
- 02_ruway/takiy/takiy-tanda/muestras/partisana/escritorio.opus +— −—
- 02_ruway/takiy/takiy-tanda/muestras/partisana/mensaje.opus +— −—
- 02_ruway/takiy/takiy-tanda/muestras/partisana/notificacion.opus +— −—
- 02_ruway/takiy/takiy-tanda/muestras/partisana/ventana-abre.opus +— −—
- 02_ruway/takiy/takiy-tanda/muestras/partisana/ventana-cierra.opus +— −—
- 02_ruway/takiy/takiy-tanda/muestras/partisana/volumen.opus +— −—
- 02_ruway/takiy/takiy-tanda/muestras/piano-seguida.opus +— −—
- 02_ruway/takiy/takiy-tanda/muestras/piano/advertencia.opus +— −—
- 02_ruway/takiy/takiy-tanda/muestras/piano/bateria.opus +— −—
- 02_ruway/takiy/takiy-tanda/muestras/piano/bienvenida.opus +— −—
- 02_ruway/takiy/takiy-tanda/muestras/piano/bloqueo.opus +— −—
- 02_ruway/takiy/takiy-tanda/muestras/piano/borrado.opus +— −—
- 02_ruway/takiy/takiy-tanda/muestras/piano/captura.opus +— −—
- 02_ruway/takiy/takiy-tanda/muestras/piano/completado.opus +— −—
- 02_ruway/takiy/takiy-tanda/muestras/piano/conecta.opus +— −—
- 02_ruway/takiy/takiy-tanda/muestras/piano/desbloqueo.opus +— −—
- 02_ruway/takiy/takiy-tanda/muestras/piano/desconecta.opus +— −—
- 02_ruway/takiy/takiy-tanda/muestras/piano/despedida.opus +— −—
- 02_ruway/takiy/takiy-tanda/muestras/piano/error.opus +— −—
- 02_ruway/takiy/takiy-tanda/muestras/piano/escritorio.opus +— −—
- 02_ruway/takiy/takiy-tanda/muestras/piano/mensaje.opus +— −—
- 02_ruway/takiy/takiy-tanda/muestras/piano/notificacion.opus +— −—
- 02_ruway/takiy/takiy-tanda/muestras/piano/ventana-abre.opus +— −—
- 02_ruway/takiy/takiy-tanda/muestras/piano/ventana-cierra.opus +— −—
- 02_ruway/takiy/takiy-tanda/muestras/piano/volumen.opus +— −—
- 02_ruway/takiy/takiy-tanda/muestras/sf2/andina-seguida.opus +— −—
- 02_ruway/takiy/takiy-tanda/muestras/sf2/piano-seguida.opus +— −—
- 02_ruway/takiy/takiy-tanda/src/biblioteca.rs +318 −0
- 02_ruway/takiy/takiy-tanda/src/bin/tanda.rs +192 −0
- 02_ruway/takiy/takiy-tanda/src/cosecha.rs +251 −0
- 02_ruway/takiy/takiy-tanda/src/evento.rs +501 −0
- 02_ruway/takiy/takiy-tanda/src/lib.rs +131 −0
- 02_ruway/takiy/takiy-tanda/src/muestras.rs +303 −0
- 02_ruway/takiy/takiy-tanda/src/paleta.rs +885 −0
- 02_ruway/takiy/takiy-tanda/src/prosa.rs +559 −0
- 02_ruway/takiy/takiy-tanda/src/render.rs +830 −0
- 02_ruway/takiy/takiy-tanda/src/reproductor.rs +223 −0
- 02_ruway/takiy/takiy-tanda/src/tanda.rs +447 −0
- Cargo.lock +958 −599
- Cargo.toml +403 −216
diff --git a/02_ruway/takiy/LEEME.md b/02_ruway/takiy/LEEME.md
index 45c3e92..1af91d5 100644
--- a/02_ruway/takiy/LEEME.md
+++ b/02_ruway/takiy/LEEME.md
@@ -30,10 +30,13 @@ cargo run --release -p takiy-app-llimphi
| [`takiy-editor-core`](takiy-editor-core/) | Estado editable puro del piano roll (score + selección + pista activa); cero audio, cero UI. |
| [`takiy-proyecto`](takiy-proyecto/) | Un proyecto de takiy con versionado DAG (commits direccionados por contenido, mismo patrón que `pluma-proyecto`). |
| [`takiy-genesis`](takiy-genesis/) | El motor generativo: de un `Brief` (tonalidad, tempo, ánimo, estilo, semilla) arma una pieza multipista completa, determinista. |
+| [`takiy-acordes`](takiy-acordes/) | Pensar una canción desde acordes y ritmos: la paleta de acordes de una tonalidad, el banco de patrones rítmicos (prediseñados y creables), la progresión editable y el render determinista a un `Score` multipista. |
| [`takiy-narrativa`](takiy-narrativa/) | Narrativa → música: sonifica un haz de cuerpos de `pluma` a un score nativo. |
-| [`takiy-escucha`](takiy-escucha/) | Las dos escuchas: en vez de generar desde una intención, se parte de sonido que ya existe. |
+| [`takiy-escucha`](takiy-escucha/) | Las escuchas: en vez de generar desde una intención, se parte de sonido que ya existe. La tercera, `armar`, va de un tarareo a la canción entera. |
| [`takiy-ambiente`](takiy-ambiente/) | El paisaje sonoro del escritorio, derivado de señales de ambiente agnósticas (hora, contexto de usuario activo). |
| `takiy-{genesis,narrativa,escucha}-llm` | Puentes LLM que **refinan** los motores deterministas de arriba: prosa entra, `Brief`/`Patch` sale. El motor sigue siendo determinista; el modelo sólo propone parámetros. |
+| [`takiy-karaoke`](takiy-karaoke/) | Cantar encima de una pista ajena: base sin voz, retardo de la toma **medido**, mezcla y puntaje de afinación. |
+| [`takiy-karaoke-llimphi`](takiy-karaoke-llimphi/) | La sala de karaoke: la letra barrida sílaba a sílaba (la lee `media-core`), el micrófono abierto y el puntaje al final. |
| [`takiy-app-llimphi`](takiy-app-llimphi/README.md) | UI Llimphi (secuenciador + síntesis). |
## Consideraciones
diff --git a/02_ruway/takiy/README.md b/02_ruway/takiy/README.md
index 2d7f1cf..ed97ecd 100644
--- a/02_ruway/takiy/README.md
+++ b/02_ruway/takiy/README.md
@@ -30,10 +30,13 @@ cargo run --release -p takiy-app-llimphi
| [`takiy-editor-core`](takiy-editor-core/) | Pure editable state of the piano roll (score + selection + active track); no audio, no UI. |
| [`takiy-proyecto`](takiy-proyecto/) | A takiy project with DAG versioning (content-addressed commits, same pattern as `pluma-proyecto`). |
| [`takiy-genesis`](takiy-genesis/) | The generative engine: from a `Brief` (key, tempo, mood, style, seed) it builds a complete multi-track piece, deterministically. |
+| [`takiy-acordes`](takiy-acordes/) | Writing a song from chords and rhythm: the chord palette of a key, a bank of rhythm patterns (built-in and user-made), an editable progression, and a deterministic render to a multi-track `Score`. |
| [`takiy-narrativa`](takiy-narrativa/) | Narrative → music: sonifies a `pluma` multilienzo bundle into a native score. |
-| [`takiy-escucha`](takiy-escucha/) | The two listenings: instead of generating music from an intent, it starts from sound that already exists. |
+| [`takiy-escucha`](takiy-escucha/) | The listenings: instead of generating music from an intent, it starts from sound that already exists. The third one, `armar`, turns a hummed melody into a whole song. |
| [`takiy-ambiente`](takiy-ambiente/) | The desktop's soundscape, derived from agnostic ambient signals (time of day, active user context). |
| `takiy-{genesis,narrativa,escucha}-llm` | LLM bridges that *refine* the deterministic engines above — prose in, `Brief`/`Patch` out. The engine stays deterministic; the model only proposes parameters. |
+| [`takiy-karaoke`](takiy-karaoke/) | Singing over someone else's track: backing without vocals, a **measured** take latency, mixdown and a pitch score. |
+| [`takiy-karaoke-llimphi`](takiy-karaoke-llimphi/) | The karaoke room: lyrics wiped syllable by syllable (parsed by `media-core`), the mic open and the score at the end. |
| [`takiy-app-llimphi`](takiy-app-llimphi/README.md) | The sequencer app. |
## Considerations
diff --git a/02_ruway/takiy/takiy-acordes/Cargo.toml b/02_ruway/takiy/takiy-acordes/Cargo.toml
new file mode 100644
index 0000000..7b7326d
--- /dev/null
+++ b/02_ruway/takiy/takiy-acordes/Cargo.toml
@@ -0,0 +1,16 @@
+[package]
+name = "takiy-acordes"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+authors.workspace = true
+publish.workspace = true
+description = "takiy — pensar una canción desde acordes y ritmos: paleta de acordes de una tonalidad, banco de patrones rítmicos (prediseñados y creables), progresión editable y render determinista a un Score multipista. Sin audio ni UI."
+
+[dependencies]
+takiy-core = { path = "../takiy-core" }
+takiy-genesis = { path = "../takiy-genesis" }
+serde = { workspace = true }
+
+[dev-dependencies]
+serde_json = { workspace = true }
diff --git a/02_ruway/takiy/takiy-acordes/LEEME.md b/02_ruway/takiy/takiy-acordes/LEEME.md
new file mode 100644
index 0000000..a1e1101
--- /dev/null
+++ b/02_ruway/takiy/takiy-acordes/LEEME.md
@@ -0,0 +1,23 @@
+# takiy-acordes
+
+*Read this in English: [README.md](README.md).*
+
+> Pensar una canción **desde los acordes y el ritmo**, no desde las notas.
+
+El piano roll de takiy (`takiy-editor-core`) trabaja nota por nota, y `takiy-genesis`
+compone una pieza entera de un tirón desde un brief. Entre esos dos extremos faltaba la
+forma en que la gente escribe una canción con una guitarra en la mano: elegir una tonalidad,
+encadenar cuatro acordes y ponerles un ritmo — un rasgueo, un arpegio, un patrón.
+
+| módulo | rol |
+|---|---|
+| `paleta` | la paleta de acordes de una tonalidad |
+| `patron` · `banco` | patrones rítmicos, prediseñados y creables |
+| `progresion` | la progresión editable |
+| `cancion` · `render` | render determinista a un `Score` multipista |
+
+Sin audio y sin UI: lo que sale es un `Score`, y tocarlo es trabajo de otro.
+
+---
+
+Parte de **takiy** — ver [takiy](../LEEME.md).
diff --git a/02_ruway/takiy/takiy-acordes/README.md b/02_ruway/takiy/takiy-acordes/README.md
new file mode 100644
index 0000000..5b19c8f
--- /dev/null
+++ b/02_ruway/takiy/takiy-acordes/README.md
@@ -0,0 +1,21 @@
+# takiy-acordes
+
+> Thinking a song **from the chords and the rhythm**, not from the notes.
+
+takiy's piano roll (`takiy-editor-core`) works note by note, and `takiy-genesis` composes a
+whole piece in one shot from a brief. Between those two extremes, the way people actually
+write a song with a guitar in their hands was missing: pick a key, chain four chords, and
+give them a rhythm — a strum, an arpeggio, a pattern.
+
+| module | role |
+|---|---|
+| `paleta` | the chord palette of a key |
+| `patron` · `banco` | rhythmic patterns, prebuilt and creatable |
+| `progresion` | the editable progression |
+| `cancion` · `render` | deterministic render to a multitrack `Score` |
+
+No audio, no UI: what comes out is a `Score`, and playing it is somebody else's job.
+
+---
+
+Part of **takiy** — see [takiy](../README.md).
diff --git a/02_ruway/takiy/takiy-acordes/src/banco.rs b/02_ruway/takiy/takiy-acordes/src/banco.rs
new file mode 100644
index 0000000..153dfa1
--- /dev/null
+++ b/02_ruway/takiy/takiy-acordes/src/banco.rs
@@ -0,0 +1,332 @@
+//! El **banco de patrones**: los prediseñados de fábrica y los del usuario,
+//! en una sola lista indexable.
+//!
+//! Los prediseñados son inmutables a propósito. Pedir editar uno no lo
+//! rompe: lo **copia** al final del banco y devuelve el índice de la copia
+//! ([`Banco::editar`]). Ésa es la costura entre «prediseñados» y
+//! «creables» — la única forma de tener un patrón propio es partir de uno
+//! que ya sonaba, o de la grilla vacía.
+
+use serde::{Deserialize, Serialize};
+
+use crate::patron::{Direccion::*, Golpe::*, Paso, Patron};
+
+/// Atajo: un paso que suena con velocidad y largo dados.
+fn p(golpe: crate::patron::Golpe, vel: u8, largo: f32) -> Paso {
+ Paso::nuevo(golpe, vel, largo)
+}
+
+/// Atajo: silencio.
+fn s() -> Paso {
+ Paso::default()
+}
+
+/// Los patrones de fábrica, en orden de menor a mayor movimiento.
+pub fn de_fabrica() -> Vec<Patron> {
+ vec![
+ // Un acorde por compás, dejado sonar. La balada.
+ Patron::desde(
+ "Redonda",
+ 0.10,
+ vec![p(Rasgueo(Abajo), 88, 8.0), s(), s(), s(), s(), s(), s(), s()],
+ ),
+ // Cuatro golpes secos: el acompañamiento de piano más simple.
+ Patron::desde(
+ "Negras",
+ 0.06,
+ vec![
+ p(Rasgueo(Abajo), 104, 2.0), s(),
+ p(Rasgueo(Abajo), 88, 2.0), s(),
+ p(Rasgueo(Abajo), 96, 2.0), s(),
+ p(Rasgueo(Abajo), 88, 2.0), s(),
+ ],
+ ),
+ // El rasgueo de guitarra de toda canción pop: ↓ ↓↑ ↑↓↑.
+ Patron::desde(
+ "Rasgueo pop",
+ 0.18,
+ vec![
+ p(Rasgueo(Abajo), 108, 2.0), s(),
+ p(Rasgueo(Abajo), 96, 1.0), p(Rasgueo(Arriba), 78, 1.0),
+ s(), p(Rasgueo(Arriba), 78, 1.0),
+ p(Rasgueo(Abajo), 100, 1.0), p(Rasgueo(Arriba), 76, 1.0),
+ ],
+ ),
+ // El mismo espíritu en semicorcheas: más aire entre golpe y golpe.
+ Patron::desde(
+ "Rasgueo folk",
+ 0.20,
+ vec![
+ p(Rasgueo(Abajo), 110, 4.0), s(), s(), s(),
+ p(Rasgueo(Abajo), 94, 2.0), s(),
+ p(Rasgueo(Arriba), 74, 2.0), s(),
+ s(), s(),
+ p(Rasgueo(Arriba), 74, 2.0), s(),
+ p(Rasgueo(Abajo), 100, 2.0), s(),
+ p(Rasgueo(Arriba), 72, 2.0), s(),
+ ],
+ ),
+ // Pum-chas: fundamental al 1 y al 3, acorde al 2 y al 4.
+ Patron::desde(
+ "Pum-chas",
+ 0.08,
+ vec![
+ p(Fundamental, 104, 2.0), s(),
+ p(Rasgueo(Abajo), 90, 1.5), s(),
+ p(Fundamental, 96, 2.0), s(),
+ p(Rasgueo(Abajo), 90, 1.5), s(),
+ ],
+ ),
+ // Arpegios: una voz por paso, dejadas sonar (la mano abierta).
+ Patron::desde(
+ "Arpegio sube",
+ 0.0,
+ vec![
+ p(Voz(0), 92, 3.0), p(Voz(1), 84, 3.0),
+ p(Voz(2), 88, 3.0), p(Voz(3), 82, 3.0),
+ p(Voz(0), 90, 3.0), p(Voz(1), 82, 3.0),
+ p(Voz(2), 86, 3.0), p(Voz(3), 80, 3.0),
+ ],
+ ),
+ Patron::desde(
+ "Arpegio baja",
+ 0.0,
+ vec![
+ p(Voz(3), 92, 3.0), p(Voz(2), 84, 3.0),
+ p(Voz(1), 88, 3.0), p(Voz(0), 82, 3.0),
+ p(Voz(3), 90, 3.0), p(Voz(2), 82, 3.0),
+ p(Voz(1), 86, 3.0), p(Voz(0), 80, 3.0),
+ ],
+ ),
+ // Alternado: fundamental de ancla y voces altas entre medio.
+ Patron::desde(
+ "Arpegio alterno",
+ 0.0,
+ vec![
+ p(Fundamental, 96, 4.0), p(Voz(2), 80, 2.0),
+ p(Voz(1), 84, 2.0), p(Voz(2), 78, 2.0),
+ p(Fundamental, 92, 4.0), p(Voz(3), 80, 2.0),
+ p(Voz(1), 84, 2.0), p(Voz(2), 78, 2.0),
+ ],
+ ),
+ // Reggae: sólo los contratiempos, cortos.
+ Patron::desde(
+ "Contratiempo",
+ 0.10,
+ vec![
+ s(), p(Rasgueo(Arriba), 96, 0.5),
+ s(), p(Rasgueo(Arriba), 88, 0.5),
+ s(), p(Rasgueo(Arriba), 96, 0.5),
+ s(), p(Rasgueo(Arriba), 88, 0.5),
+ ],
+ ),
+ // Bossa: la síncopa característica en semicorcheas.
+ Patron::desde(
+ "Bossa",
+ 0.06,
+ vec![
+ p(Rasgueo(Abajo), 96, 3.0), s(), s(),
+ p(Rasgueo(Abajo), 84, 3.0), s(), s(),
+ p(Rasgueo(Abajo), 90, 4.0), s(), s(), s(),
+ p(Rasgueo(Abajo), 84, 2.0), s(),
+ p(Rasgueo(Abajo), 88, 2.0), s(), s(), s(),
+ ],
+ ),
+ // Vals: tres tiempos — bajo, acorde, acorde. Seis pasos, no ocho.
+ Patron::desde(
+ "Vals",
+ 0.10,
+ vec![
+ p(Fundamental, 100, 2.0), s(),
+ p(Rasgueo(Abajo), 84, 1.5), s(),
+ p(Rasgueo(Abajo), 80, 1.5), s(),
+ ],
+ ),
+ ]
+}
+
+/// La lista completa de patrones de una sesión: primero los de fábrica,
+/// después los del usuario.
+#[derive(Debug, Clone)]
+pub struct Banco {
+ patrones: Vec<Patron>,
+ fijos: usize,
+}
+
+/// Lo único que se guarda en disco: los patrones propios. Los de fábrica
+/// vienen del código, así que una versión nueva los mejora sin migración.
+#[derive(Debug, Clone, Default, Serialize, Deserialize)]
+pub struct BancoGuardado {
+ pub propios: Vec<Patron>,
+}
+
+impl Default for Banco {
+ fn default() -> Self {
+ Self::nuevo()
+ }
+}
+
+impl Banco {
+ pub fn nuevo() -> Self {
+ let patrones = de_fabrica();
+ let fijos = patrones.len();
+ Self { patrones, fijos }
+ }
+
+ /// Banco de fábrica + los propios que venían de disco.
+ pub fn con_propios(propios: Vec<Patron>) -> Self {
+ let mut b = Self::nuevo();
+ b.patrones.extend(propios);
+ b
+ }
+
+ pub fn patrones(&self) -> &[Patron] {
+ &self.patrones
+ }
+
+ pub fn len(&self) -> usize {
+ self.patrones.len()
+ }
+
+ pub fn is_empty(&self) -> bool {
+ false
+ }
+
+ pub fn get(&self, i: usize) -> Option<&Patron> {
+ self.patrones.get(i)
+ }
+
+ /// El patrón `i`, o el primero de fábrica si el índice quedó viejo.
+ /// El render nunca se queda sin patrón.
+ pub fn get_o_primero(&self, i: usize) -> &Patron {
+ self.patrones.get(i).unwrap_or(&self.patrones[0])
+ }
+
+ pub fn es_fijo(&self, i: usize) -> bool {
+ i < self.fijos
+ }
+
+ pub fn fijos(&self) -> usize {
+ self.fijos
+ }
+
+ /// Los propios, para guardar.
+ pub fn propios(&self) -> &[Patron] {
+ &self.patrones[self.fijos..]
+ }
+
+ pub fn guardado(&self) -> BancoGuardado {
+ BancoGuardado { propios: self.propios().to_vec() }
+ }
+
+ /// Devuelve un índice **editable** para `i`: si `i` es de fábrica, lo
+ /// copia al final con nombre libre y devuelve el índice de la copia.
+ pub fn editar(&mut self, i: usize) -> usize {
+ if !self.es_fijo(i) {
+ return i.min(self.patrones.len().saturating_sub(1));
+ }
+ let mut copia = self.patrones[i].clone();
+ copia.nombre = self.nombre_libre(&copia.nombre);
+ self.patrones.push(copia);
+ self.patrones.len() - 1
+ }
+
+ /// Agrega un patrón propio y devuelve su índice.
+ pub fn agregar(&mut self, mut patron: Patron) -> usize {
+ patron.nombre = self.nombre_libre(&patron.nombre);
+ self.patrones.push(patron);
+ self.patrones.len() - 1
+ }
+
+ /// Muta un patrón propio. Sobre uno de fábrica no hace nada — el caller
+ /// tiene que pasar por [`Banco::editar`] primero.
+ pub fn con_mut<R>(&mut self, i: usize, f: impl FnOnce(&mut Patron) -> R) -> Option<R> {
+ if self.es_fijo(i) {
+ return None;
+ }
+ self.patrones.get_mut(i).map(f)
+ }
+
+ /// Borra un patrón propio. Los de fábrica no se borran.
+ pub fn quitar(&mut self, i: usize) -> bool {
+ if self.es_fijo(i) || i >= self.patrones.len() {
+ return false;
+ }
+ self.patrones.remove(i);
+ true
+ }
+
+ /// Un nombre que no choque con ninguno del banco: «Rasgueo pop 2», 3…
+ fn nombre_libre(&self, base: &str) -> String {
+ if !self.patrones.iter().any(|p| p.nombre == base) {
+ return base.to_string();
+ }
+ for n in 2..999 {
+ let cand = format!("{base} {n}");
+ if !self.patrones.iter().any(|p| p.nombre == cand) {
+ return cand;
+ }
+ }
+ base.to_string()
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn todos_los_de_fabrica_suenan_y_tienen_nombre_unico() {
+ let b = Banco::nuevo();
+ assert!(b.len() >= 10);
+ for p in b.patrones() {
+ assert!(p.ataques() > 0, "«{}» no toca nada", p.nombre);
+ assert!(!p.nombre.is_empty());
+ }
+ let mut nombres: Vec<_> = b.patrones().iter().map(|p| p.nombre.as_str()).collect();
+ nombres.sort_unstable();
+ let antes = nombres.len();
+ nombres.dedup();
+ assert_eq!(nombres.len(), antes, "hay nombres repetidos");
+ }
+
+ #[test]
+ fn editar_un_fijo_lo_copia_en_vez_de_romperlo() {
+ let mut b = Banco::nuevo();
+ let original = b.get(2).unwrap().clone();
+ let i = b.editar(2);
+ assert!(i >= b.fijos(), "la copia tiene que ser propia");
+ b.con_mut(i, |p| p.limpiar());
+ assert_eq!(b.get(2).unwrap(), &original, "el de fábrica quedó intacto");
+ assert_eq!(b.get(i).unwrap().ataques(), 0);
+ assert_ne!(b.get(i).unwrap().nombre, original.nombre);
+ }
+
+ #[test]
+ fn los_fijos_no_se_borran_ni_se_mutan() {
+ let mut b = Banco::nuevo();
+ assert!(!b.quitar(0));
+ assert!(b.con_mut(0, |p| p.limpiar()).is_none());
+ let i = b.agregar(Patron::vacio("mío", 8));
+ assert!(b.quitar(i));
+ assert_eq!(b.len(), b.fijos());
+ }
+
+ #[test]
+ fn solo_los_propios_van_a_disco_y_vuelven() {
+ let mut b = Banco::nuevo();
+ b.agregar(Patron::vacio("mío", 8));
+ let json = serde_json::to_string(&b.guardado()).unwrap();
+ let leido: BancoGuardado = serde_json::from_str(&json).unwrap();
+ assert_eq!(leido.propios.len(), 1);
+ let b2 = Banco::con_propios(leido.propios);
+ assert_eq!(b2.len(), b.len());
+ assert_eq!(b2.propios()[0].nombre, "mío");
+ }
+
+ #[test]
+ fn el_indice_viejo_no_deja_al_render_sin_patron() {
+ let b = Banco::nuevo();
+ assert_eq!(b.get_o_primero(9999).nombre, b.get(0).unwrap().nombre);
+ }
+}
diff --git a/02_ruway/takiy/takiy-acordes/src/cancion.rs b/02_ruway/takiy/takiy-acordes/src/cancion.rs
new file mode 100644
index 0000000..8ea4113
--- /dev/null
+++ b/02_ruway/takiy/takiy-acordes/src/cancion.rs
@@ -0,0 +1,97 @@
+//! La **canción** entera como un solo valor serializable: la tonalidad
+//! elegida, la cadena de acordes, los ajustes de render y los patrones
+//! propios que se usaron.
+//!
+//! Guardar los patrones propios *adentro* de la canción y no sólo en el
+//! banco del usuario es a propósito: un archivo que viaja a otra máquina
+//! tiene que sonar igual, y los patrones de fábrica pueden no alcanzar.
+//! Al abrirla, [`Cancion::banco`] rearma el banco con ellos.
+
+use serde::{Deserialize, Serialize};
+
+use crate::banco::Banco;
+use crate::paleta::Paleta;
+use crate::patron::Patron;
+use crate::progresion::Progresion;
+use crate::render::{render, Ajustes};
+
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub struct Cancion {
+ pub nombre: String,
+ pub paleta: Paleta,
+ pub progresion: Progresion,
+ pub ajustes: Ajustes,
+ /// Patrones que no son de fábrica y que la canción necesita.
+ #[serde(default)]
+ pub propios: Vec<Patron>,
+}
+
+impl Default for Cancion {
+ fn default() -> Self {
+ Self {
+ nombre: "Sin título".into(),
+ paleta: Paleta::default(),
+ progresion: Progresion::nueva(),
+ ajustes: Ajustes::default(),
+ propios: Vec::new(),
+ }
+ }
+}
+
+impl Cancion {
+ /// Una canción de arranque: la cadencia más usada del mundo en la
+ /// tonalidad de la paleta. Nadie empieza con la pantalla vacía.
+ pub fn de_arranque(paleta: Paleta) -> Self {
+ Self {
+ progresion: Progresion::cadencia(&paleta, 0),
+ paleta,
+ ..Self::default()
+ }
+ }
+
+ /// El banco que hace falta para tocarla: fábrica + los propios.
+ pub fn banco(&self) -> Banco {
+ Banco::con_propios(self.propios.clone())
+ }
+
+ /// La canción hecha `Score`.
+ pub fn score(&self) -> takiy_core::Score {
+ render(&self.progresion, &self.banco(), &self.ajustes)
+ }
+
+ /// Guarda en la canción los patrones propios del banco que está usando
+ /// la sesión. Se llama antes de escribir a disco.
+ pub fn absorber_propios(&mut self, banco: &Banco) {
+ self.propios = banco.propios().to_vec();
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::patron::{Golpe, Patron};
+
+ #[test]
+ fn la_cancion_de_arranque_ya_suena() {
+ let c = Cancion::de_arranque(Paleta::default());
+ assert_eq!(c.progresion.len(), 4);
+ assert!(!c.score().tracks()[0].is_empty());
+ }
+
+ #[test]
+ fn un_patron_propio_sobrevive_el_viaje_a_disco() {
+ let mut c = Cancion::de_arranque(Paleta::default());
+ let mut banco = c.banco();
+ let i = banco.agregar(Patron::vacio("mío", 4));
+ banco.con_mut(i, |p| p.set_golpe(0, Golpe::Fundamental));
+ c.absorber_propios(&banco);
+ c.ajustes.patron = i;
+
+ let json = serde_json::to_string(&c).unwrap();
+ let leida: Cancion = serde_json::from_str(&json).unwrap();
+ assert_eq!(leida, c);
+ // Y el índice sigue apuntando al patrón propio, no a otro.
+ assert_eq!(leida.banco().get(i).unwrap().nombre, "mío");
+ assert_eq!(leida.score().tracks()[0].len(), c.progresion.len());
+ }
+}
diff --git a/02_ruway/takiy/takiy-acordes/src/lib.rs b/02_ruway/takiy/takiy-acordes/src/lib.rs
new file mode 100644
index 0000000..366d625
--- /dev/null
+++ b/02_ruway/takiy/takiy-acordes/src/lib.rs
@@ -0,0 +1,49 @@
+//! `takiy-acordes` — pensar una canción **desde los acordes y el ritmo**,
+//! no desde las notas.
+//!
+//! El piano roll de takiy (`takiy-editor-core`) trabaja nota por nota, y
+//! `takiy-genesis` compone una pieza entera de un tirón desde un brief.
+//! Entre esos dos extremos falta la forma en que la gente escribe una
+//! canción con una guitarra en la mano: elegir una tonalidad, encadenar
+//! cuatro acordes, y ponerles un ritmo — un rasgueo, un arpegio, un
+//! pum-chas — que se puede cambiar de a uno.
+//!
+//! Cuatro piezas, todas agnósticas de audio y de UI:
+//!
+//! - [`paleta`] — los acordes que *tienen sentido* en una tonalidad
+//! (los siete grados) y la matriz libre de 12 raíces × cualidades.
+//! - [`patron`] — un patrón rítmico como una grilla de pasos por compás.
+//! Cada paso es un golpe: silencio, rasgueo (arriba/abajo), una voz
+//! suelta del acorde (arpegio) o la fundamental.
+//! - [`banco`] — los patrones **prediseñados**, y el banco donde conviven
+//! con los que crea el usuario.
+//! - [`cancion`] — todo eso junto en un valor que se guarda y se abre.
+//! - [`progresion`] — la cadena de acordes: cada ranura es un acorde con
+//! su largo en compases, su octava, su inversión y —si quiere— su
+//! propio patrón.
+//! - [`quintas`] — el **círculo de quintas** como función: distancia
+//! armónica entre tonalidades, la relativa, a dónde modular, y los cinco
+//! acordes **prestados** que usa la música popular, cada uno con el
+//! nombre de lo que hace.
+//! - [`render`] — progresión + patrones → [`takiy_core::Score`]
+//! multipista (acordes, bajo, batería), determinista: la misma entrada
+//! da las mismas notas. De ahí en adelante suena, se edita en el piano
+//! roll y se exporta a MIDI/WAV con lo que ya existe.
+
+#![forbid(unsafe_code)]
+
+pub mod banco;
+pub mod cancion;
+pub mod paleta;
+pub mod patron;
+pub mod progresion;
+pub mod quintas;
+pub mod render;
+
+pub use banco::Banco;
+pub use cancion::Cancion;
+pub use paleta::{Paleta, TipoTonalidad, CUALIDADES};
+pub use patron::{Direccion, Golpe, Paso, Patron};
+pub use progresion::{Progresion, Ranura};
+pub use quintas::{prestados, Prestado};
+pub use render::{render, voces_de, Ajustes};
diff --git a/02_ruway/takiy/takiy-acordes/src/paleta.rs b/02_ruway/takiy/takiy-acordes/src/paleta.rs
new file mode 100644
index 0000000..5e1aa57
--- /dev/null
+++ b/02_ruway/takiy/takiy-acordes/src/paleta.rs
@@ -0,0 +1,234 @@
+//! La **paleta**: qué acordes tienen sentido en una tonalidad.
+//!
+//! Dos maneras de elegir, que es como se elige de verdad:
+//!
+//! - por **grado** — los siete acordes de la tonalidad, con su número
+//! romano. Es la paleta con la que se escribe una canción sin saber
+//! teoría: cualquier combinación de esos siete suena.
+//! - por **matriz libre** — las doce raíces × las cualidades. Para el
+//! acorde prestado, el dominante secundario, o el que uno escuchó y
+//! quiere probar.
+//!
+//! La cualidad de cada grado no está tabulada: sale de apilar terceras
+//! dentro de la escala (`takiy_genesis::harmony::diatonic_chord`), así el
+//! ii es menor en modo mayor y el V es mayor en menor sin caso especial.
+
+use serde::{Deserialize, Serialize};
+use takiy_core::{Chord, ChordQuality, PitchClass, Scale};
+
+/// Modo de la tonalidad. Los dos que cubren casi toda canción; el resto se
+/// arma a mano desde la matriz libre.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+pub enum TipoTonalidad {
+ Mayor,
+ Menor,
+}
+
+impl TipoTonalidad {
+ pub fn etiqueta(self) -> &'static str {
+ match self {
+ TipoTonalidad::Mayor => "mayor",
+ TipoTonalidad::Menor => "menor",
+ }
+ }
+
+ pub fn escala(self, tonica: PitchClass) -> Scale {
+ match self {
+ TipoTonalidad::Mayor => Scale::major(tonica),
+ TipoTonalidad::Menor => Scale::natural_minor(tonica),
+ }
+ }
+}
+
+/// Las cualidades que ofrece la matriz libre, en el orden en que se pintan.
+pub const CUALIDADES: &[ChordQuality] = &[
+ ChordQuality::Major,
+ ChordQuality::Minor,
+ ChordQuality::Dominant7,
+ ChordQuality::Minor7,
+ ChordQuality::Major7,
+ ChordQuality::Sus2,
+ ChordQuality::Sus4,
+ ChordQuality::Sixth,
+ ChordQuality::MinorSixth,
+ ChordQuality::Add9,
+ ChordQuality::Dominant9,
+ ChordQuality::Minor9,
+ ChordQuality::Power5,
+ ChordQuality::Diminished,
+ ChordQuality::Diminished7,
+ ChordQuality::HalfDiminished,
+ ChordQuality::Augmented,
+];
+
+/// Las doce raíces, en orden cromático desde Do.
+pub const RAICES: [PitchClass; 12] = [
+ PitchClass::C,
+ PitchClass::Cs,
+ PitchClass::D,
+ PitchClass::Ds,
+ PitchClass::E,
+ PitchClass::F,
+ PitchClass::Fs,
+ PitchClass::G,
+ PitchClass::Gs,
+ PitchClass::A,
+ PitchClass::As,
+ PitchClass::B,
+];
+
+const ROMANOS: [&str; 7] = ["I", "II", "III", "IV", "V", "VI", "VII"];
+
+/// Una tonalidad elegida: tónica + modo.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+pub struct Paleta {
+ pub tonica: PitchClass,
+ pub tipo: TipoTonalidad,
+ /// Si los grados salen con séptima (jazz/soul) o como tríadas (pop).
+ pub septimas: bool,
+}
+
+impl Default for Paleta {
+ fn default() -> Self {
+ Self { tonica: PitchClass::C, tipo: TipoTonalidad::Mayor, septimas: false }
+ }
+}
+
+impl Paleta {
+ pub fn nueva(tonica: PitchClass, tipo: TipoTonalidad) -> Self {
+ Self { tonica, tipo, septimas: false }
+ }
+
+ pub fn escala(&self) -> Scale {
+ self.tipo.escala(self.tonica)
+ }
+
+ /// Nombre de la tonalidad — «C mayor», «A menor».
+ pub fn nombre(&self) -> String {
+ format!("{} {}", self.tonica.name(), self.tipo.etiqueta())
+ }
+
+ /// El acorde del grado `deg` (0 = tónica).
+ pub fn grado(&self, deg: usize) -> Chord {
+ takiy_genesis::harmony::diatonic_chord(&self.escala(), deg, self.septimas)
+ }
+
+ /// Los siete grados con su número romano listo para pintar.
+ pub fn grados(&self) -> Vec<(String, Chord)> {
+ (0..7)
+ .map(|d| {
+ let ch = self.grado(d);
+ (romano(d, ch.quality), ch)
+ })
+ .collect()
+ }
+
+ /// La matriz libre: [`CUALIDADES`] × 12 raíces, una fila por cualidad.
+ /// El índice de una celda es `fila * 12 + columna`.
+ pub fn matriz(&self) -> Vec<Chord> {
+ let mut out = Vec::with_capacity(12 * CUALIDADES.len());
+ for &q in CUALIDADES {
+ for &r in &RAICES {
+ out.push(Chord::new(r, q));
+ }
+ }
+ out
+ }
+
+ /// `true` si todas las notas del acorde caen dentro de la tonalidad —
+ /// el dato con el que la UI marca «éste es de la casa».
+ pub fn es_diatonico(&self, ch: Chord) -> bool {
+ let escala = self.escala();
+ let grados: Vec<u8> = (0..escala.degree_count())
+ .map(|d| escala.degree(d).semitone())
+ .collect();
+ ch.quality
+ .intervals()
+ .iter()
+ .all(|&iv| grados.contains(&((ch.root.semitone() + iv) % 12)))
+ }
+
+ /// El grado que ocupa este acorde en la tonalidad, si es uno de los
+ /// siete. Sirve para mostrar «V» arriba de un Sol en Do mayor.
+ pub fn grado_de(&self, ch: Chord) -> Option<usize> {
+ (0..7).find(|&d| self.grado(d) == ch)
+ }
+}
+
+/// El número romano del grado `deg` teñido por la cualidad: `V`, `vi`,
+/// `vii°`, `III+`.
+pub fn romano(deg: usize, q: ChordQuality) -> String {
+ let base = ROMANOS[deg % 7];
+ match q {
+ ChordQuality::Minor
+ | ChordQuality::Minor7
+ | ChordQuality::MinorSixth
+ | ChordQuality::Minor9 => base.to_lowercase(),
+ ChordQuality::Diminished | ChordQuality::Diminished7 => {
+ format!("{}°", base.to_lowercase())
+ }
+ ChordQuality::HalfDiminished => format!("{}ø", base.to_lowercase()),
+ ChordQuality::Augmented => format!("{base}+"),
+ _ => base.to_string(),
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn los_grados_de_do_mayor_son_los_del_cancionero() {
+ let p = Paleta::nueva(PitchClass::C, TipoTonalidad::Mayor);
+ let nombres: Vec<String> = p.grados().iter().map(|(_, c)| c.name()).collect();
+ assert_eq!(nombres, ["C", "Dm", "Em", "F", "G", "Am", "Bdim"]);
+ let romanos: Vec<String> = p.grados().into_iter().map(|(r, _)| r).collect();
+ assert_eq!(romanos, ["I", "ii", "iii", "IV", "V", "vi", "vii°"]);
+ }
+
+ #[test]
+ fn los_grados_de_la_menor_comparten_notas_con_do_mayor() {
+ let mayor = Paleta::nueva(PitchClass::C, TipoTonalidad::Mayor);
+ let menor = Paleta::nueva(PitchClass::A, TipoTonalidad::Menor);
+ let mut a: Vec<String> = mayor.grados().iter().map(|(_, c)| c.name()).collect();
+ let mut b: Vec<String> = menor.grados().iter().map(|(_, c)| c.name()).collect();
+ a.sort();
+ b.sort();
+ assert_eq!(a, b, "relativas: los mismos siete acordes");
+ }
+
+ #[test]
+ fn diatonico_distingue_al_prestado() {
+ let p = Paleta::nueva(PitchClass::C, TipoTonalidad::Mayor);
+ assert!(p.es_diatonico(Chord::new(PitchClass::F, ChordQuality::Major)));
+ // El dominante secundario V/V (Re mayor) trae un fa# ajeno.
+ assert!(!p.es_diatonico(Chord::new(PitchClass::D, ChordQuality::Major)));
+ }
+
+ #[test]
+ fn la_matriz_cubre_todas_las_combinaciones() {
+ let p = Paleta::default();
+ let m = p.matriz();
+ assert_eq!(m.len(), 12 * CUALIDADES.len());
+ assert_eq!(m[0].name(), "C");
+ // Fila 1 = menores: la celda 12 es Do menor.
+ assert_eq!(m[12].name(), "Cm");
+ }
+
+ #[test]
+ fn el_grado_se_reconoce_al_volver() {
+ let p = Paleta::nueva(PitchClass::G, TipoTonalidad::Mayor);
+ let quinto = p.grado(4);
+ assert_eq!(quinto.name(), "D");
+ assert_eq!(p.grado_de(quinto), Some(4));
+ assert_eq!(p.grado_de(Chord::new(PitchClass::Cs, ChordQuality::Major7)), None);
+ }
+
+ #[test]
+ fn con_septimas_los_grados_se_colorean() {
+ let p = Paleta { septimas: true, ..Paleta::nueva(PitchClass::C, TipoTonalidad::Mayor) };
+ let nombres: Vec<String> = p.grados().iter().map(|(_, c)| c.name()).collect();
+ assert_eq!(nombres[0], "Cmaj7");
+ assert_eq!(nombres[4], "G7");
+ }
+}
diff --git a/02_ruway/takiy/takiy-acordes/src/patron.rs b/02_ruway/takiy/takiy-acordes/src/patron.rs
new file mode 100644
index 0000000..707c107
--- /dev/null
+++ b/02_ruway/takiy/takiy-acordes/src/patron.rs
@@ -0,0 +1,258 @@
+//! Un **patrón rítmico**: la grilla de pasos con que se toca un acorde
+//! durante un compás.
+//!
+//! Es agnóstico del acorde. El mismo patrón anda con una tríada y con una
+//! novena porque los golpes de arpegio nombran la voz **por índice módulo
+//! la cantidad de voces**, no por altura absoluta. Y es agnóstico del
+//! compás: `pasos_por_compas` divide el compás que le pase el render, así
+//! el mismo modelo sirve para 4/4 y para un vals.
+
+use serde::{Deserialize, Serialize};
+
+/// Sentido del rasgueo. Define el **orden** en que entran las voces y, con
+/// él, el color: hacia abajo entra primero la fundamental (grave→agudo);
+/// hacia arriba entra primero la voz más aguda.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+pub enum Direccion {
+ Abajo,
+ Arriba,
+}
+
+impl Direccion {
+ pub fn invertida(self) -> Self {
+ match self {
+ Direccion::Abajo => Direccion::Arriba,
+ Direccion::Arriba => Direccion::Abajo,
+ }
+ }
+
+ /// Símbolo para la grilla — el mismo que usa cualquier cancionero.
+ pub fn simbolo(self) -> &'static str {
+ match self {
+ Direccion::Abajo => "↓",
+ Direccion::Arriba => "↑",
+ }
+ }
+}
+
+/// Qué pasa en un paso de la grilla.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+pub enum Golpe {
+ /// Nada. El acorde anterior sigue sonando si su largo lo alcanza.
+ Silencio,
+ /// Todas las voces del acorde, separadas por el ancho de rasgueo.
+ Rasgueo(Direccion),
+ /// Una sola voz (arpegio). El índice se toma módulo la cantidad de
+ /// voces del acorde, para que el patrón no dependa de la cualidad.
+ Voz(u8),
+ /// La fundamental sola — el «pum» del pum-chas. La toca la pista de
+ /// acordes; la pista de bajo es otra cosa.
+ Fundamental,
+}
+
+impl Golpe {
+ /// `true` si el paso produce sonido.
+ pub fn suena(self) -> bool {
+ !matches!(self, Golpe::Silencio)
+ }
+
+ /// Etiqueta corta para pintar la celda de la grilla.
+ pub fn simbolo(self) -> String {
+ match self {
+ Golpe::Silencio => "·".into(),
+ Golpe::Rasgueo(d) => d.simbolo().into(),
+ Golpe::Voz(i) => format!("{}", i + 1),
+ Golpe::Fundamental => "●".into(),
+ }
+ }
+
+ /// El siguiente golpe del ciclo que recorre un click sobre la celda:
+ /// silencio → ↓ → ↑ → fundamental → voz 1..4 → silencio.
+ pub fn siguiente(self) -> Golpe {
+ match self {
+ Golpe::Silencio => Golpe::Rasgueo(Direccion::Abajo),
+ Golpe::Rasgueo(Direccion::Abajo) => Golpe::Rasgueo(Direccion::Arriba),
+ Golpe::Rasgueo(Direccion::Arriba) => Golpe::Fundamental,
+ Golpe::Fundamental => Golpe::Voz(0),
+ Golpe::Voz(i) if i < 3 => Golpe::Voz(i + 1),
+ Golpe::Voz(_) => Golpe::Silencio,
+ }
+ }
+}
+
+/// Un paso de la grilla: qué golpe, con cuánta fuerza y cuánto dura.
+#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
+pub struct Paso {
+ pub golpe: Golpe,
+ /// Velocidad MIDI (1..=127). Lo que hace que un rasgueo tenga acento.
+ pub velocidad: u8,
+ /// Largo en **pasos** de la grilla. `1.0` = hasta el próximo paso;
+ /// `4.0` = deja sonar cuatro pasos (la ligadura del cancionero).
+ pub largo: f32,
+}
+
+impl Default for Paso {
+ fn default() -> Self {
+ Self { golpe: Golpe::Silencio, velocidad: 96, largo: 1.0 }
+ }
+}
+
+impl Paso {
+ pub fn nuevo(golpe: Golpe, velocidad: u8, largo: f32) -> Self {
+ Self { golpe, velocidad, largo }
+ }
+
+ /// Un golpe con los valores de siempre (velocidad 96, un paso).
+ pub fn de(golpe: Golpe) -> Self {
+ Self { golpe, ..Self::default() }
+ }
+}
+
+/// El patrón completo. `pasos.len()` **es** `pasos_por_compas`: la grilla
+/// es siempre de un compás y el render la repite.
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub struct Patron {
+ pub nombre: String,
+ /// Separación entre voces de un rasgueo, en **fracción de paso**. 0 es
+ /// un acorde de piano (todas juntas); 0.25 es una guitarra.
+ pub ancho_rasgueo: f32,
+ pasos: Vec<Paso>,
+}
+
+impl Patron {
+ /// Un patrón vacío de `n` pasos (todos silencio).
+ pub fn vacio(nombre: impl Into<String>, n: usize) -> Self {
+ Self {
+ nombre: nombre.into(),
+ ancho_rasgueo: 0.18,
+ pasos: vec![Paso::default(); n.max(1)],
+ }
+ }
+
+ /// Un patrón desde sus pasos ya armados.
+ pub fn desde(nombre: impl Into<String>, ancho_rasgueo: f32, pasos: Vec<Paso>) -> Self {
+ let pasos = if pasos.is_empty() { vec![Paso::default()] } else { pasos };
+ Self { nombre: nombre.into(), ancho_rasgueo, pasos }
+ }
+
+ pub fn pasos(&self) -> &[Paso] {
+ &self.pasos
+ }
+
+ pub fn pasos_por_compas(&self) -> usize {
+ self.pasos.len()
+ }
+
+ pub fn paso(&self, i: usize) -> Option<&Paso> {
+ self.pasos.get(i)
+ }
+
+ /// Cicla el golpe de la celda `i` — es el click de la grilla.
+ pub fn ciclar(&mut self, i: usize) {
+ if let Some(p) = self.pasos.get_mut(i) {
+ p.golpe = p.golpe.siguiente();
+ }
+ }
+
+ pub fn set_golpe(&mut self, i: usize, golpe: Golpe) {
+ if let Some(p) = self.pasos.get_mut(i) {
+ p.golpe = golpe;
+ }
+ }
+
+ pub fn set_velocidad(&mut self, i: usize, v: u8) {
+ if let Some(p) = self.pasos.get_mut(i) {
+ p.velocidad = v.clamp(1, 127);
+ }
+ }
+
+ pub fn set_largo(&mut self, i: usize, largo: f32) {
+ if let Some(p) = self.pasos.get_mut(i) {
+ p.largo = largo.max(0.05);
+ }
+ }
+
+ /// Cambia la resolución de la grilla conservando lo que ya había:
+ /// al agrandar, los pasos nuevos entran en silencio; al achicar, se
+ /// recortan los del final.
+ pub fn redimensionar(&mut self, n: usize) {
+ let n = n.clamp(1, 64);
+ self.pasos.resize(n, Paso::default());
+ }
+
+ /// Corre el patrón `k` pasos a la derecha (rotación circular). Sirve
+ /// para probar el mismo rasgueo empezando en el contratiempo.
+ pub fn rotar(&mut self, k: i32) {
+ let n = self.pasos.len() as i32;
+ if n == 0 {
+ return;
+ }
+ let k = ((k % n) + n) % n;
+ self.pasos.rotate_right(k as usize);
+ }
+
+ pub fn limpiar(&mut self) {
+ for p in &mut self.pasos {
+ p.golpe = Golpe::Silencio;
+ }
+ }
+
+ /// Cantidad de pasos que suenan — el número que dice si el patrón está
+ /// vacío sin tener que mirarlo.
+ pub fn ataques(&self) -> usize {
+ self.pasos.iter().filter(|p| p.golpe.suena()).count()
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn el_ciclo_de_golpes_vuelve_al_silencio() {
+ let mut g = Golpe::Silencio;
+ let mut vistos = vec![g];
+ for _ in 0..16 {
+ g = g.siguiente();
+ if g == Golpe::Silencio {
+ break;
+ }
+ vistos.push(g);
+ }
+ assert_eq!(g, Golpe::Silencio, "el ciclo tiene que cerrar");
+ assert_eq!(vistos.len(), 8, "silencio + 2 rasgueos + fundamental + 4 voces");
+ }
+
+ #[test]
+ fn redimensionar_conserva_lo_editado() {
+ let mut p = Patron::vacio("mío", 8);
+ p.set_golpe(0, Golpe::Rasgueo(Direccion::Abajo));
+ p.set_golpe(7, Golpe::Voz(2));
+ p.redimensionar(16);
+ assert_eq!(p.pasos_por_compas(), 16);
+ assert_eq!(p.paso(0).unwrap().golpe, Golpe::Rasgueo(Direccion::Abajo));
+ assert_eq!(p.paso(7).unwrap().golpe, Golpe::Voz(2));
+ assert_eq!(p.paso(15).unwrap().golpe, Golpe::Silencio);
+ p.redimensionar(4);
+ assert_eq!(p.ataques(), 1);
+ }
+
+ #[test]
+ fn rotar_es_circular_y_no_pierde_ataques() {
+ let mut p = Patron::vacio("mío", 8);
+ p.set_golpe(0, Golpe::Fundamental);
+ p.set_golpe(4, Golpe::Rasgueo(Direccion::Arriba));
+ p.rotar(2);
+ assert_eq!(p.paso(2).unwrap().golpe, Golpe::Fundamental);
+ assert_eq!(p.paso(6).unwrap().golpe, Golpe::Rasgueo(Direccion::Arriba));
+ p.rotar(-2);
+ assert_eq!(p.paso(0).unwrap().golpe, Golpe::Fundamental);
+ assert_eq!(p.ataques(), 2);
+ }
+
+ #[test]
+ fn un_patron_vacio_nunca_tiene_cero_pasos() {
+ assert_eq!(Patron::vacio("x", 0).pasos_por_compas(), 1);
+ assert_eq!(Patron::desde("x", 0.1, Vec::new()).pasos_por_compas(), 1);
+ }
+}
diff --git a/02_ruway/takiy/takiy-acordes/src/progresion.rs b/02_ruway/takiy/takiy-acordes/src/progresion.rs
new file mode 100644
index 0000000..21c480a
--- /dev/null
+++ b/02_ruway/takiy/takiy-acordes/src/progresion.rs
@@ -0,0 +1,246 @@
+//! La **progresión**: la cadena de acordes de la canción.
+//!
+//! Cada eslabón es una [`Ranura`] — un acorde con su largo en compases, su
+//! octava, su inversión y, si quiere, su propio patrón rítmico. Que el
+//! patrón sea por ranura y no global es lo que deja «pensar» una canción:
+//! el estribillo entra con otro rasgueo sin tocar el resto.
+
+use serde::{Deserialize, Serialize};
+use takiy_core::{Chord, PitchClass};
+
+use crate::paleta::Paleta;
+
+/// Un acorde en la cadena.
+#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
+pub struct Ranura {
+ pub acorde: Chord,
+ /// Largo en compases. `1.0` es lo normal; `0.5` parte el compás en dos.
+ pub compases: f32,
+ /// Patrón propio de esta ranura. `None` = el patrón de la canción.
+ pub patron: Option<usize>,
+ /// Octava de la fundamental del voicing.
+ pub octava: i32,
+ /// Inversión: cuántas voces suben una octava (0 = estado fundamental).
+ pub inversion: u8,
+}
+
+impl Ranura {
+ pub fn nueva(acorde: Chord) -> Self {
+ Self { acorde, compases: 1.0, patron: None, octava: 4, inversion: 0 }
+ }
+
+ /// El nombre que se pinta en la ranura — «Am», «G7».
+ pub fn nombre(&self) -> String {
+ self.acorde.name()
+ }
+}
+
+/// Progresiones célebres por grados (0 = I). Son el arranque: se sueltan
+/// en la cadena y de ahí se editan.
+pub const CADENCIAS: &[(&str, &[usize])] = &[
+ ("I–V–vi–IV", &[0, 4, 5, 3]),
+ ("vi–IV–I–V", &[5, 3, 0, 4]),
+ ("I–vi–IV–V", &[0, 5, 3, 4]),
+ ("I–IV–V–I", &[0, 3, 4, 0]),
+ ("ii–V–I", &[1, 4, 0]),
+ ("I–vi–ii–V", &[0, 5, 1, 4]),
+ ("i–VI–III–VII", &[0, 5, 2, 6]),
+ ("I–IV–vi–V", &[0, 3, 5, 4]),
+ ("Canon (I–V–vi–iii–IV–I–IV–V)", &[0, 4, 5, 2, 3, 0, 3, 4]),
+ ("Blues de 12", &[0, 0, 0, 0, 3, 3, 0, 0, 4, 3, 0, 4]),
+];
+
+/// La cadena completa.
+#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
+pub struct Progresion {
+ ranuras: Vec<Ranura>,
+}
+
+impl Progresion {
+ pub fn nueva() -> Self {
+ Self::default()
+ }
+
+ pub fn desde(ranuras: Vec<Ranura>) -> Self {
+ Self { ranuras }
+ }
+
+ /// La cadena que sale de aplicar una lista de grados a una tonalidad.
+ pub fn desde_grados(paleta: &Paleta, grados: &[usize]) -> Self {
+ Self { ranuras: grados.iter().map(|&d| Ranura::nueva(paleta.grado(d))).collect() }
+ }
+
+ /// Una cadencia del catálogo, por índice en [`CADENCIAS`].
+ pub fn cadencia(paleta: &Paleta, i: usize) -> Self {
+ let (_, grados) = CADENCIAS.get(i).copied().unwrap_or(CADENCIAS[0]);
+ Self::desde_grados(paleta, grados)
+ }
+
+ pub fn ranuras(&self) -> &[Ranura] {
+ &self.ranuras
+ }
+
+ pub fn len(&self) -> usize {
+ self.ranuras.len()
+ }
+
+ pub fn is_empty(&self) -> bool {
+ self.ranuras.is_empty()
+ }
+
+ pub fn ranura(&self, i: usize) -> Option<&Ranura> {
+ self.ranuras.get(i)
+ }
+
+ pub fn ranura_mut(&mut self, i: usize) -> Option<&mut Ranura> {
+ self.ranuras.get_mut(i)
+ }
+
+ pub fn agregar(&mut self, acorde: Chord) -> usize {
+ self.ranuras.push(Ranura::nueva(acorde));
+ self.ranuras.len() - 1
+ }
+
+ pub fn insertar(&mut self, i: usize, ranura: Ranura) {
+ let i = i.min(self.ranuras.len());
+ self.ranuras.insert(i, ranura);
+ }
+
+ pub fn quitar(&mut self, i: usize) -> Option<Ranura> {
+ (i < self.ranuras.len()).then(|| self.ranuras.remove(i))
+ }
+
+ pub fn duplicar(&mut self, i: usize) -> Option<usize> {
+ let r = *self.ranuras.get(i)?;
+ self.ranuras.insert(i + 1, r);
+ Some(i + 1)
+ }
+
+ /// Mueve la ranura `desde` a la posición `hasta` (el drag de la cinta).
+ pub fn mover(&mut self, desde: usize, hasta: usize) -> bool {
+ if desde >= self.ranuras.len() || hasta >= self.ranuras.len() || desde == hasta {
+ return false;
+ }
+ let r = self.ranuras.remove(desde);
+ self.ranuras.insert(hasta, r);
+ true
+ }
+
+ /// Transpone toda la cadena. Las octavas no se tocan: sube la raíz de
+ /// cada acorde dentro de su octava, que es lo que hace un capo.
+ pub fn transponer(&mut self, semitonos: i32) {
+ for r in &mut self.ranuras {
+ let s = (r.acorde.root.semitone() as i32 + semitonos).rem_euclid(12) as u8;
+ r.acorde.root = PitchClass::from_semitone(s);
+ }
+ }
+
+ /// Largo total en compases.
+ pub fn compases(&self) -> f32 {
+ self.ranuras.iter().map(|r| r.compases).sum()
+ }
+
+ /// El compás en el que arranca cada ranura — lo que necesita la UI para
+ /// pintar la cinta y el render para ubicar las notas.
+ pub fn inicios(&self) -> Vec<f32> {
+ let mut acc = 0.0;
+ self.ranuras
+ .iter()
+ .map(|r| {
+ let inicio = acc;
+ acc += r.compases;
+ inicio
+ })
+ .collect()
+ }
+
+ /// Repite la cadena `n` veces (el «×2» de un estribillo).
+ pub fn repetir(&mut self, n: usize) {
+ if n <= 1 {
+ return;
+ }
+ let base = self.ranuras.clone();
+ for _ in 1..n {
+ self.ranuras.extend(base.iter().copied());
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::paleta::TipoTonalidad;
+
+ fn do_mayor() -> Paleta {
+ Paleta::nueva(PitchClass::C, TipoTonalidad::Mayor)
+ }
+
+ #[test]
+ fn la_cadencia_mas_usada_del_mundo() {
+ let p = Progresion::cadencia(&do_mayor(), 0);
+ let nombres: Vec<String> = p.ranuras().iter().map(|r| r.nombre()).collect();
+ assert_eq!(nombres, ["C", "G", "Am", "F"]);
+ assert_eq!(p.compases(), 4.0);
+ assert_eq!(p.inicios(), vec![0.0, 1.0, 2.0, 3.0]);
+ }
+
+ #[test]
+ fn mover_reordena_sin_perder_ranuras() {
+ let mut p = Progresion::cadencia(&do_mayor(), 0);
+ assert!(p.mover(0, 3));
+ let nombres: Vec<String> = p.ranuras().iter().map(|r| r.nombre()).collect();
+ assert_eq!(nombres, ["G", "Am", "F", "C"]);
+ assert_eq!(p.len(), 4);
+ assert!(!p.mover(0, 0));
+ assert!(!p.mover(9, 1));
+ }
+
+ #[test]
+ fn transponer_cinco_semitonos_lleva_do_a_fa() {
+ let mut p = Progresion::cadencia(&do_mayor(), 0);
+ p.transponer(5);
+ let nombres: Vec<String> = p.ranuras().iter().map(|r| r.nombre()).collect();
+ assert_eq!(nombres, ["F", "C", "Dm", "A#"]);
+ // Y vuelve.
+ p.transponer(-5);
+ assert_eq!(p.ranura(0).unwrap().nombre(), "C");
+ }
+
+ #[test]
+ fn los_inicios_respetan_largos_distintos() {
+ let mut p = Progresion::cadencia(&do_mayor(), 0);
+ p.ranura_mut(0).unwrap().compases = 2.0;
+ p.ranura_mut(1).unwrap().compases = 0.5;
+ assert_eq!(p.inicios(), vec![0.0, 2.0, 2.5, 3.5]);
+ assert_eq!(p.compases(), 4.5);
+ }
+
+ #[test]
+ fn duplicar_y_quitar_son_inversas() {
+ let mut p = Progresion::cadencia(&do_mayor(), 0);
+ let i = p.duplicar(1).unwrap();
+ assert_eq!(p.len(), 5);
+ assert_eq!(p.ranura(i).unwrap().nombre(), "G");
+ p.quitar(i);
+ assert_eq!(p.len(), 4);
+ assert!(p.quitar(99).is_none());
+ }
+
+ #[test]
+ fn repetir_tilea_la_cadena() {
+ let mut p = Progresion::cadencia(&do_mayor(), 4); // ii–V–I
+ p.repetir(3);
+ assert_eq!(p.len(), 9);
+ assert_eq!(p.ranura(3).unwrap().nombre(), p.ranura(0).unwrap().nombre());
+ }
+
+ #[test]
+ fn todas_las_cadencias_del_catalogo_dan_acordes() {
+ let paleta = do_mayor();
+ for (i, (nombre, grados)) in CADENCIAS.iter().enumerate() {
+ let p = Progresion::cadencia(&paleta, i);
+ assert_eq!(p.len(), grados.len(), "«{nombre}»");
+ assert!(p.compases() > 0.0);
+ }
+ }
+}
diff --git a/02_ruway/takiy/takiy-acordes/src/quintas.rs b/02_ruway/takiy/takiy-acordes/src/quintas.rs
new file mode 100644
index 0000000..dfcfaa0
--- /dev/null
+++ b/02_ruway/takiy/takiy-acordes/src/quintas.rs
@@ -0,0 +1,284 @@
+//! El **círculo de quintas**: el mapa con el que se navega la armonía.
+//!
+//! No es un dibujo bonito, es una función: dos tonalidades vecinas en el
+//! círculo comparten seis de sus siete notas, así que *«qué acorde puedo
+//! meter que no rompa nada»* y *«a dónde puedo modular»* son la misma
+//! pregunta —cuántos pasos de quinta hay de acá a allá— y se contestan
+//! contando, no probando.
+//!
+//! Lo que da esta pieza y no daba [`crate::paleta`]: la paleta contesta
+//! «los siete acordes de tu tonalidad, todos suenan». Eso alcanza para la
+//! primera canción y se queda corto en la segunda, cuando el que la escribe
+//! ya escuchó un acorde que *no* está entre los siete y no sabe nombrarlo.
+//! Los **prestados** de acá son esos: los cinco que la música popular usa
+//! todo el tiempo, cada uno con el nombre de lo que hace.
+
+use serde::{Deserialize, Serialize};
+use takiy_core::{Chord, ChordQuality, PitchClass};
+
+use crate::paleta::{Paleta, TipoTonalidad};
+
+/// Las doce tónicas en orden de quintas ascendentes desde Do. Es el orden
+/// en que se pinta el círculo (a favor del reloj) y el que define la
+/// distancia armónica.
+pub const ORDEN: [PitchClass; 12] = [
+ PitchClass::C,
+ PitchClass::G,
+ PitchClass::D,
+ PitchClass::A,
+ PitchClass::E,
+ PitchClass::B,
+ PitchClass::Fs,
+ PitchClass::Cs,
+ PitchClass::Gs,
+ PitchClass::Ds,
+ PitchClass::As,
+ PitchClass::F,
+];
+
+/// Posición de una clase en el círculo (0 = Do, 1 = Sol, …).
+pub fn posicion(pc: PitchClass) -> usize {
+ ORDEN.iter().position(|&x| x == pc).unwrap_or(0)
+}
+
+/// La clase que está `pasos` quintas más arriba (negativo = hacia las
+/// cuartas, o sea a la izquierda del círculo).
+pub fn mover(pc: PitchClass, pasos: i32) -> PitchClass {
+ let i = posicion(pc) as i32 + pasos;
+ ORDEN[i.rem_euclid(12) as usize]
+}
+
+/// Distancia armónica: cuántos pasos de quinta separan dos tonalidades,
+/// por el camino más corto (0..=6). Dos tonalidades a un paso comparten
+/// seis notas; a seis pasos, apenas dos.
+pub fn distancia(a: PitchClass, b: PitchClass) -> usize {
+ let d = (posicion(a) as i32 - posicion(b) as i32).rem_euclid(12);
+ d.min(12 - d) as usize
+}
+
+/// La tonalidad **relativa** (mayor ↔ menor): la que tiene las mismas
+/// siete notas. La menor relativa está una tercera menor abajo de la mayor.
+pub fn relativa(paleta: Paleta) -> Paleta {
+ match paleta.tipo {
+ TipoTonalidad::Mayor => Paleta {
+ tonica: desplazar(paleta.tonica, -3),
+ tipo: TipoTonalidad::Menor,
+ septimas: paleta.septimas,
+ },
+ TipoTonalidad::Menor => Paleta {
+ tonica: desplazar(paleta.tonica, 3),
+ tipo: TipoTonalidad::Mayor,
+ septimas: paleta.septimas,
+ },
+ }
+}
+
+/// Modula `pasos` posiciones en el círculo conservando el modo. Un paso a
+/// la derecha (dominante) es la modulación más suave que existe.
+pub fn modular(paleta: Paleta, pasos: i32) -> Paleta {
+ Paleta { tonica: mover(paleta.tonica, pasos), ..paleta }
+}
+
+/// Corre una clase `semis` semitonos.
+fn desplazar(pc: PitchClass, semis: i32) -> PitchClass {
+ PitchClass::from_semitone(((pc.semitone() as i32 + semis).rem_euclid(12)) as u8)
+}
+
+/// Un acorde que **no** está en la tonalidad pero que se usa igual, con el
+/// nombre de lo que hace. Es el vocabulario con el que un arreglo deja de
+/// sonar a ejercicio.
+#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
+pub struct Prestado {
+ pub acorde: Chord,
+ /// Qué es, en una palabra — «dominante secundario», «préstamo modal».
+ pub que_es: &'static str,
+ /// Para qué sirve, en una frase — se muestra al pasar por encima.
+ pub para_que: &'static str,
+}
+
+/// Los prestados de uso corriente para una tonalidad. Cinco, no cincuenta:
+/// son los que aparecen en la música que la gente escucha, y cada uno
+/// cambia algo que se oye.
+pub fn prestados(paleta: Paleta) -> Vec<Prestado> {
+ let t = paleta.tonica;
+ let mayor = matches!(paleta.tipo, TipoTonalidad::Mayor);
+ let mut v = Vec::new();
+
+ // V/V — el dominante del dominante: empuja al V con más fuerza que
+ // cualquier acorde de la casa.
+ v.push(Prestado {
+ acorde: Chord::new(mover(t, 2), ChordQuality::Dominant7),
+ que_es: "dominante secundario (V/V)",
+ para_que: "empuja al quinto grado; el acorde que hace que el estribillo entre",
+ });
+
+ if mayor {
+ // iv menor — el préstamo modal clásico: nubla el final.
+ v.push(Prestado {
+ acorde: Chord::new(desplazar(t, 5), ChordQuality::Minor),
+ que_es: "cuarto menor (préstamo modal)",
+ para_que: "agridulce: el mismo acorde de siempre, pero triste, justo antes de volver a casa",
+ });
+ // bVII — el acorde del rock, prestado del modo mixolidio.
+ v.push(Prestado {
+ acorde: Chord::new(desplazar(t, 10), ChordQuality::Major),
+ que_es: "séptimo bemol (bVII)",
+ para_que: "suena a rock y a himno; cae bien antes de la tónica",
+ });
+ // bVI — préstamo del menor, para el puente.
+ v.push(Prestado {
+ acorde: Chord::new(desplazar(t, 8), ChordQuality::Major),
+ que_es: "sexto bemol (bVI)",
+ para_que: "abre el puente: el aire cambia de color sin irse de tono",
+ });
+ // vi7 no es prestado; el quinto sí, con séptima, para tensión.
+ v.push(Prestado {
+ acorde: Chord::new(desplazar(t, 7), ChordQuality::Dominant7),
+ que_es: "quinto con séptima (V7)",
+ para_que: "la vuelta a casa con ganas; es el V de siempre, pidiendo resolver",
+ });
+ } else {
+ // V mayor en menor (menor armónica): la cadencia que suena a final.
+ v.push(Prestado {
+ acorde: Chord::new(desplazar(t, 7), ChordQuality::Dominant7),
+ que_es: "quinto mayor (menor armónica)",
+ para_que: "en menor, el quinto natural no cierra; éste sí — es el final de verdad",
+ });
+ // IV mayor — el color dórico.
+ v.push(Prestado {
+ acorde: Chord::new(desplazar(t, 5), ChordQuality::Major),
+ que_es: "cuarto mayor (color dórico)",
+ para_que: "levanta el ánimo del menor sin salir de él",
+ });
+ // bII — napolitano.
+ v.push(Prestado {
+ acorde: Chord::new(desplazar(t, 1), ChordQuality::Major),
+ que_es: "napolitano (bII)",
+ para_que: "dramático, de película; va justo antes del quinto",
+ });
+ // Tónica mayor: la tercera de Picardía. (El III de la relativa mayor
+ // NO entra acá: en menor natural es diatónico, no prestado.)
+ v.push(Prestado {
+ acorde: Chord::new(t, ChordQuality::Major),
+ que_es: "tónica mayor (tercera de Picardía)",
+ para_que: "el final que se abre: la última vez, el acorde de casa suena mayor",
+ });
+ }
+ v
+}
+
+/// Las dos tonalidades vecinas —la de la izquierda (subdominante) y la de
+/// la derecha (dominante)— más la relativa. Son los tres destinos de
+/// modulación que no suenan a error.
+pub fn destinos(paleta: Paleta) -> Vec<(Paleta, &'static str)> {
+ vec![
+ (modular(paleta, 1), "un paso a la derecha: sube el brillo"),
+ (modular(paleta, -1), "un paso a la izquierda: se ablanda"),
+ (relativa(paleta), "la relativa: mismas notas, otro humor"),
+ ]
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn el_orden_del_circulo_es_por_quintas() {
+ for i in 0..12 {
+ let a = ORDEN[i];
+ let b = ORDEN[(i + 1) % 12];
+ let salto = (b.semitone() as i32 - a.semitone() as i32).rem_euclid(12);
+ assert_eq!(salto, 7, "de {} a {} no hay una quinta", a.name(), b.name());
+ }
+ }
+
+ #[test]
+ fn las_doce_estan_una_sola_vez() {
+ let mut vistas = std::collections::HashSet::new();
+ for pc in ORDEN {
+ assert!(vistas.insert(pc.semitone()), "{} repetida", pc.name());
+ }
+ assert_eq!(vistas.len(), 12);
+ }
+
+ #[test]
+ fn la_distancia_es_simetrica_y_corta() {
+ assert_eq!(distancia(PitchClass::C, PitchClass::C), 0);
+ assert_eq!(distancia(PitchClass::C, PitchClass::G), 1);
+ assert_eq!(distancia(PitchClass::G, PitchClass::C), 1);
+ assert_eq!(distancia(PitchClass::C, PitchClass::F), 1, "la cuarta es un paso, no once");
+ assert_eq!(distancia(PitchClass::C, PitchClass::Fs), 6, "el tritono es lo más lejos");
+ for a in ORDEN {
+ for b in ORDEN {
+ assert_eq!(distancia(a, b), distancia(b, a));
+ assert!(distancia(a, b) <= 6);
+ }
+ }
+ }
+
+ #[test]
+ fn la_relativa_comparte_las_siete_notas() {
+ let mayor = Paleta::nueva(PitchClass::C, TipoTonalidad::Mayor);
+ let menor = relativa(mayor);
+ assert_eq!(menor.tonica, PitchClass::A);
+ assert!(matches!(menor.tipo, TipoTonalidad::Menor));
+ // Ida y vuelta.
+ assert_eq!(relativa(menor).tonica, PitchClass::C);
+ // Y las notas son las mismas.
+ let notas = |p: Paleta| {
+ let e = p.escala();
+ let mut v: Vec<u8> = (0..7).map(|d| e.degree(d).semitone()).collect();
+ v.sort_unstable();
+ v
+ };
+ assert_eq!(notas(mayor), notas(menor));
+ }
+
+ #[test]
+ fn los_prestados_son_ajenos_a_la_tonalidad_pero_no_al_oido() {
+ for tipo in [TipoTonalidad::Mayor, TipoTonalidad::Menor] {
+ let paleta = Paleta::nueva(PitchClass::C, tipo);
+ let ps = prestados(paleta);
+ assert_eq!(ps.len(), 5, "cinco prestados, ni más ni menos");
+ let escala = paleta.escala();
+ let grados: Vec<u8> = (0..7).map(|d| escala.degree(d).semitone()).collect();
+ for p in &ps {
+ assert!(!p.que_es.is_empty() && !p.para_que.is_empty());
+ // Un prestado es prestado: o su raíz es de afuera, o su
+ // cualidad no es la que la tonalidad le da a ese grado.
+ let dentro = grados.contains(&p.acorde.root.semitone());
+ let diatonico = dentro
+ && takiy_genesis::harmony::diatonic_chord(
+ &escala,
+ grados.iter().position(|&g| g == p.acorde.root.semitone()).unwrap(),
+ false,
+ )
+ .quality
+ == p.acorde.quality;
+ assert!(!diatonico, "{} no es un prestado: ya está en la tonalidad", p.acorde.name());
+ }
+ }
+ }
+
+ #[test]
+ fn modular_un_paso_cambia_una_sola_nota() {
+ let c = Paleta::nueva(PitchClass::C, TipoTonalidad::Mayor);
+ let g = modular(c, 1);
+ assert_eq!(g.tonica, PitchClass::G);
+ let notas = |p: Paleta| {
+ let e = p.escala();
+ (0..7).map(|d| e.degree(d).semitone()).collect::<std::collections::HashSet<_>>()
+ };
+ let comunes = notas(c).intersection(¬as(g)).count();
+ assert_eq!(comunes, 6, "vecinas en el círculo comparten seis notas");
+ }
+
+ #[test]
+ fn los_tres_destinos_son_distintos_del_origen() {
+ let c = Paleta::nueva(PitchClass::C, TipoTonalidad::Mayor);
+ for (p, por_que) in destinos(c) {
+ assert!(!por_que.is_empty());
+ assert!(p.tonica != c.tonica || !matches!(p.tipo, TipoTonalidad::Mayor));
+ }
+ }
+}
diff --git a/02_ruway/takiy/takiy-acordes/src/render.rs b/02_ruway/takiy/takiy-acordes/src/render.rs
new file mode 100644
index 0000000..25f46d0
--- /dev/null
+++ b/02_ruway/takiy/takiy-acordes/src/render.rs
@@ -0,0 +1,394 @@
+//! De la progresión a un [`Score`] — el momento en que la idea suena.
+//!
+//! Determinista: la misma progresión con los mismos patrones y ajustes da
+//! exactamente las mismas notas. Lo que sale es un `Score` normal y
+//! corriente, así que sigue de largo por lo que ya existe: se reproduce
+//! (`takiy-playback`), se edita nota por nota en el piano roll, se exporta
+//! a MIDI (`takiy-midi`) o a WAV.
+
+use serde::{Deserialize, Serialize};
+use takiy_core::{Pitch, Score, ScoreNote, Track};
+use takiy_genesis::rng::Rng;
+use takiy_genesis::{Brief, Style};
+
+use crate::banco::Banco;
+use crate::patron::{Direccion, Golpe, Patron};
+use crate::progresion::{Progresion, Ranura};
+
+/// Cómo se baja la progresión a notas.
+#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
+pub struct Ajustes {
+ pub bpm: f32,
+ /// Tiempos por compás — 4 para 4/4, 3 para un vals.
+ pub beats_por_compas: f32,
+ /// El patrón que usan las ranuras que no eligieron uno propio.
+ pub patron: usize,
+ /// Pista de bajo con la fundamental del acorde.
+ pub bajo: bool,
+ pub octava_bajo: i32,
+ /// Pista de batería. Sólo se agrega en compás de 4 tiempos — el groove
+ /// que reusamos de `takiy-genesis` está escrito en 4/4.
+ pub bateria: bool,
+ /// Estilo del groove de batería.
+ pub estilo: Style,
+ /// Semilla del jitter de la batería (el resto es determinista sin ella).
+ pub semilla: u64,
+ /// Swing `0.0..0.5`: cuánto se atrasa cada paso impar, en fracción de
+ /// paso. `0.0` = recto.
+ pub swing: f32,
+}
+
+impl Default for Ajustes {
+ fn default() -> Self {
+ Self {
+ bpm: 100.0,
+ beats_por_compas: 4.0,
+ patron: 2, // «Rasgueo pop»
+ bajo: true,
+ octava_bajo: 2,
+ bateria: false,
+ estilo: Style::Pop,
+ semilla: 1,
+ swing: 0.0,
+ }
+ }
+}
+
+/// Las alturas concretas de una ranura: el acorde en su octava, con la
+/// inversión aplicada (cada inversión sube la voz más grave una octava).
+pub fn voces_de(ranura: &Ranura) -> Vec<Pitch> {
+ let mut voces = ranura.acorde.voicing(ranura.octava);
+ if voces.is_empty() {
+ return voces;
+ }
+ for _ in 0..ranura.inversion.min(4) {
+ let grave = voces.remove(0);
+ match grave.transpose(12) {
+ Some(p) => voces.push(p),
+ None => {
+ voces.insert(0, grave);
+ break;
+ }
+ }
+ }
+ voces.sort_by_key(|p| p.midi());
+ voces
+}
+
+/// La progresión hecha `Score`.
+pub fn render(prog: &Progresion, banco: &Banco, aj: &Ajustes) -> Score {
+ let mut score = Score::new(aj.bpm);
+ let bpc = aj.beats_por_compas.max(1.0);
+
+ let mut acordes = Track::new("Acordes");
+ let mut bajo = Track::new("Bajo");
+
+ for (ranura, inicio_compas) in prog.ranuras().iter().zip(prog.inicios()) {
+ let inicio = inicio_compas * bpc;
+ let largo = ranura.compases.max(0.05) * bpc;
+ let patron = banco.get_o_primero(ranura.patron.unwrap_or(aj.patron));
+ tocar_ranura(&mut acordes, ranura, patron, inicio, largo, bpc, aj.swing);
+ if aj.bajo {
+ tocar_bajo(&mut bajo, ranura, inicio, largo, bpc);
+ }
+ }
+
+ score.add_track(acordes);
+ if aj.bajo && !bajo.is_empty() {
+ score.add_track(bajo);
+ }
+
+ // Batería: se reusa el groove de `takiy-genesis` (perfil por estilo,
+ // patrones euclidianos). Está escrito en 4/4, así que fuera de 4/4 no
+ // se agrega — antes que sonar corrida, no suena.
+ if aj.bateria && (bpc - 4.0).abs() < f32::EPSILON {
+ let compases = prog.compases().ceil().max(1.0) as usize;
+ let brief = Brief { bars: compases, style: aj.estilo, tempo_bpm: aj.bpm, ..Brief::default() };
+ let mut rng = Rng::new(aj.semilla);
+ let bateria = takiy_genesis::rhythm::drum_section(
+ &brief, compases, 0, 1.0, false, false, false, &mut rng,
+ );
+ if !bateria.is_empty() {
+ score.add_track(bateria);
+ }
+ }
+
+ score
+}
+
+/// Un acorde tocado con su patrón, desde `inicio` y por `largo` beats.
+fn tocar_ranura(
+ track: &mut Track,
+ ranura: &Ranura,
+ patron: &Patron,
+ inicio: f32,
+ largo: f32,
+ bpc: f32,
+ swing: f32,
+) {
+ let voces = voces_de(ranura);
+ if voces.is_empty() {
+ return;
+ }
+ let n = patron.pasos_por_compas();
+ let paso_beats = bpc / n as f32;
+ // Cuántos pasos entran en la ranura (una ranura de medio compás toca
+ // la primera mitad del patrón, no el patrón entero apretado).
+ let total = (largo / paso_beats).round().max(1.0) as usize;
+ let fin = inicio + largo;
+
+ for j in 0..total {
+ let Some(paso) = patron.paso(j % n) else { continue };
+ if !paso.golpe.suena() {
+ continue;
+ }
+ let atraso = if j % 2 == 1 { swing.clamp(0.0, 0.49) * paso_beats } else { 0.0 };
+ let t = inicio + j as f32 * paso_beats + atraso;
+ if t >= fin {
+ break;
+ }
+ // Nada suena después del cambio de acorde: la ligadura se corta.
+ let dur = (paso.largo * paso_beats).min(fin - t).max(0.05);
+
+ match paso.golpe {
+ Golpe::Silencio => {}
+ Golpe::Fundamental => {
+ track.add(ScoreNote::new(voces[0], t, dur, paso.velocidad));
+ }
+ Golpe::Voz(i) => {
+ let v = voces[(i as usize) % voces.len()];
+ track.add(ScoreNote::new(v, t, dur, paso.velocidad));
+ }
+ Golpe::Rasgueo(dir) => {
+ let sep = patron.ancho_rasgueo.max(0.0) * paso_beats;
+ for (k, &v) in voces.iter().enumerate() {
+ // Hacia abajo la púa entra por la voz grave; hacia
+ // arriba, por la aguda.
+ let orden = match dir {
+ Direccion::Abajo => k,
+ Direccion::Arriba => voces.len() - 1 - k,
+ };
+ let t_v = t + orden as f32 * sep;
+ if t_v >= fin {
+ continue;
+ }
+ // Las voces que entran después pierden un poco de
+ // fuerza: es lo que hace que un rasgueo no suene a
+ // acorde de máquina.
+ let vel = (paso.velocidad as i32 - 3 * orden as i32).clamp(1, 127) as u8;
+ let dur_v = (dur - (t_v - t)).max(0.05);
+ track.add(ScoreNote::new(v, t_v, dur_v, vel));
+ }
+ }
+ }
+ }
+}
+
+/// Bajo: la fundamental en el 1 y la quinta a mitad de compás cuando la
+/// ranura dura un compás entero o más.
+fn tocar_bajo(track: &mut Track, ranura: &Ranura, inicio: f32, largo: f32, bpc: f32) {
+ let voces = voces_de(ranura);
+ let Some(root) = Pitch::from_class_octave(ranura.acorde.root, ranura.octava - 2)
+ .or_else(|| voces.first().copied())
+ else {
+ return;
+ };
+ if largo >= bpc {
+ let mitad = largo / 2.0;
+ track.add(ScoreNote::new(root, inicio, mitad.min(bpc / 2.0), 96));
+ let quinta = root.transpose(7).unwrap_or(root);
+ track.add(ScoreNote::new(quinta, inicio + mitad, largo - mitad, 84));
+ } else {
+ track.add(ScoreNote::new(root, inicio, largo, 96));
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::paleta::{Paleta, TipoTonalidad};
+ use crate::patron::{Direccion, Golpe, Paso};
+ use takiy_core::{Chord, ChordQuality, PitchClass};
+
+ fn do_mayor() -> Paleta {
+ Paleta::nueva(PitchClass::C, TipoTonalidad::Mayor)
+ }
+
+ fn ajustes() -> Ajustes {
+ Ajustes { bajo: false, bateria: false, ..Ajustes::default() }
+ }
+
+ #[test]
+ fn el_render_es_determinista() {
+ let p = Progresion::cadencia(&do_mayor(), 0);
+ let b = Banco::nuevo();
+ let a = Ajustes { bajo: true, bateria: true, ..Ajustes::default() };
+ let uno = render(&p, &b, &a);
+ let dos = render(&p, &b, &a);
+ assert_eq!(uno.tracks().len(), dos.tracks().len());
+ for (t1, t2) in uno.tracks().iter().zip(dos.tracks()) {
+ assert_eq!(t1.notes(), t2.notes(), "pista «{}»", t1.name);
+ }
+ }
+
+ #[test]
+ fn cada_acorde_cae_en_su_compas() {
+ let p = Progresion::cadencia(&do_mayor(), 0); // C G Am F
+ let mut b = Banco::nuevo();
+ // Patrón de un solo golpe al 1 para que la cuenta sea limpia.
+ let solo = b.agregar(Patron::desde(
+ "uno",
+ 0.0,
+ vec![Paso::nuevo(Golpe::Fundamental, 100, 4.0)],
+ ));
+ let score = render(&p, &b, &Ajustes { patron: solo, ..ajustes() });
+ let notas = score.track(0).unwrap().notes();
+ assert_eq!(notas.len(), 4);
+ let inicios: Vec<f32> = notas.iter().map(|n| n.start).collect();
+ assert_eq!(inicios, vec![0.0, 4.0, 8.0, 12.0]);
+ let clases: Vec<_> = notas.iter().map(|n| n.pitch.class()).collect();
+ assert_eq!(
+ clases,
+ vec![PitchClass::C, PitchClass::G, PitchClass::A, PitchClass::F]
+ );
+ }
+
+ #[test]
+ fn el_rasgueo_abre_las_voces_en_el_tiempo() {
+ let mut prog = Progresion::nueva();
+ prog.agregar(Chord::new(PitchClass::C, ChordQuality::Major));
+ let mut b = Banco::nuevo();
+ let i = b.agregar(Patron::desde(
+ "rasgueo",
+ 0.25,
+ vec![Paso::nuevo(Golpe::Rasgueo(Direccion::Abajo), 100, 4.0)],
+ ));
+ let score = render(&prog, &b, &Ajustes { patron: i, ..ajustes() });
+ let notas = score.track(0).unwrap().notes();
+ assert_eq!(notas.len(), 3, "las tres voces de la tríada");
+ // Grave primero, y separadas: es un rasgueo, no un bloque.
+ assert!(notas[0].start < notas[1].start && notas[1].start < notas[2].start);
+ assert!(notas[0].pitch.midi() < notas[1].pitch.midi());
+ // Y hacia arriba entra primero la aguda.
+ let j = b.agregar(Patron::desde(
+ "arriba",
+ 0.25,
+ vec![Paso::nuevo(Golpe::Rasgueo(Direccion::Arriba), 100, 4.0)],
+ ));
+ let score = render(&prog, &b, &Ajustes { patron: j, ..ajustes() });
+ let notas = score.track(0).unwrap().notes();
+ let primera = notas.iter().min_by(|a, b| a.start.total_cmp(&b.start)).unwrap();
+ let aguda = notas.iter().max_by_key(|n| n.pitch.midi()).unwrap();
+ assert_eq!(primera.pitch, aguda.pitch);
+ }
+
+ #[test]
+ fn ninguna_nota_pisa_el_acorde_siguiente() {
+ let p = Progresion::cadencia(&do_mayor(), 0);
+ let b = Banco::nuevo();
+ // «Redonda» deja sonar el compás entero: el corte tiene que ser exacto.
+ let score = render(&p, &b, &Ajustes { patron: 0, ..ajustes() });
+ for n in score.track(0).unwrap().notes() {
+ let compas = (n.start / 4.0).floor();
+ assert!(
+ n.end() <= (compas + 1.0) * 4.0 + 1e-3,
+ "una nota de {} se estiró hasta {}",
+ n.start,
+ n.end()
+ );
+ }
+ }
+
+ #[test]
+ fn una_ranura_de_medio_compas_toca_media_grilla() {
+ let mut prog = Progresion::nueva();
+ prog.agregar(Chord::new(PitchClass::C, ChordQuality::Major));
+ prog.ranura_mut(0).unwrap().compases = 0.5;
+ let mut b = Banco::nuevo();
+ // Ocho pasos de una nota cada uno: en medio compás tienen que
+ // sonar los primeros cuatro, no los ocho apretados.
+ let i = b.agregar(Patron::desde(
+ "ocho",
+ 0.0,
+ (0..8).map(|_| Paso::nuevo(Golpe::Fundamental, 90, 1.0)).collect(),
+ ));
+ let score = render(&prog, &b, &Ajustes { patron: i, ..ajustes() });
+ let inicios: Vec<f32> = score
+ .track(0)
+ .unwrap()
+ .notes()
+ .iter()
+ .map(|n| n.start)
+ .collect();
+ assert_eq!(inicios, vec![0.0, 0.5, 1.0, 1.5]);
+ }
+
+ #[test]
+ fn la_inversion_sube_la_voz_grave_una_octava() {
+ let mut r = Ranura::nueva(Chord::new(PitchClass::C, ChordQuality::Major));
+ let fundamental = voces_de(&r);
+ r.inversion = 1;
+ let primera = voces_de(&r);
+ assert_eq!(fundamental.len(), primera.len());
+ assert_eq!(primera[0].class(), PitchClass::E, "ahora abajo está la tercera");
+ assert_eq!(
+ primera.last().unwrap().midi(),
+ fundamental[0].midi() + 12,
+ "la fundamental subió una octava"
+ );
+ }
+
+ #[test]
+ fn el_swing_atrasa_solo_los_pasos_impares() {
+ let mut prog = Progresion::nueva();
+ prog.agregar(Chord::new(PitchClass::C, ChordQuality::Major));
+ let mut b = Banco::nuevo();
+ let i = b.agregar(Patron::desde(
+ "ocho",
+ 0.0,
+ (0..8).map(|_| Paso::nuevo(Golpe::Fundamental, 90, 1.0)).collect(),
+ ));
+ let recto = render(&prog, &b, &Ajustes { patron: i, ..ajustes() });
+ let swing = render(&prog, &b, &Ajustes { patron: i, swing: 0.3, ..ajustes() });
+ let a: Vec<f32> = recto.track(0).unwrap().notes().iter().map(|n| n.start).collect();
+ let s: Vec<f32> = swing.track(0).unwrap().notes().iter().map(|n| n.start).collect();
+ assert_eq!(a.len(), s.len());
+ for (k, (x, y)) in a.iter().zip(&s).enumerate() {
+ if k % 2 == 0 {
+ assert_eq!(x, y, "el paso {k} es a tiempo");
+ } else {
+ assert!(y > x, "el paso {k} tiene que atrasarse");
+ }
+ }
+ }
+
+ #[test]
+ fn el_bajo_y_la_bateria_entran_como_pistas_aparte() {
+ let p = Progresion::cadencia(&do_mayor(), 0);
+ let b = Banco::nuevo();
+ let score = render(&p, &b, &Ajustes { bajo: true, bateria: true, ..Ajustes::default() });
+ let nombres: Vec<&str> = score.tracks().iter().map(|t| t.name.as_str()).collect();
+ assert_eq!(nombres, vec!["Acordes", "Bajo", "Batería"]);
+ assert!(!score.track(1).unwrap().is_empty());
+ assert!(!score.track(2).unwrap().is_empty());
+ }
+
+ #[test]
+ fn en_vals_no_hay_bateria_de_cuatro_cuartos() {
+ let p = Progresion::cadencia(&do_mayor(), 0);
+ let b = Banco::nuevo();
+ let score = render(
+ &p,
+ &b,
+ &Ajustes { beats_por_compas: 3.0, bateria: true, bajo: false, ..Ajustes::default() },
+ );
+ let nombres: Vec<&str> = score.tracks().iter().map(|t| t.name.as_str()).collect();
+ assert_eq!(nombres, vec!["Acordes"]);
+ }
+
+ #[test]
+ fn una_progresion_vacia_da_un_score_sin_notas() {
+ let score = render(&Progresion::nueva(), &Banco::nuevo(), &Ajustes::default());
+ assert!(score.tracks().iter().all(|t| t.is_empty()));
+ }
+}
diff --git a/02_ruway/takiy/takiy-ambiente/examples/dia.rs b/02_ruway/takiy/takiy-ambiente/examples/dia.rs
index 121c230..953dc3a 100644
--- a/02_ruway/takiy/takiy-ambiente/examples/dia.rs
+++ b/02_ruway/takiy/takiy-ambiente/examples/dia.rs
@@ -36,6 +36,10 @@ fn main() {
apps: app.into_iter().map(String::from).collect(),
actividad,
media_activo: media,
+ // El recorrido del día mira cómo cambia el paisaje con las señales,
+ // así que la hebra se queda quieta: lo que se ve acá es lo que hace
+ // el escritorio, no la renovación por tiempo.
+ hebra: 0,
};
let franja = format!("{:?}", Franja::from_hora(hora));
diff --git a/02_ruway/takiy/takiy-ambiente/src/lib.rs b/02_ruway/takiy/takiy-ambiente/src/lib.rs
index a3321e1..cba5ccf 100644
--- a/02_ruway/takiy/takiy-ambiente/src/lib.rs
+++ b/02_ruway/takiy/takiy-ambiente/src/lib.rs
@@ -44,6 +44,15 @@ pub struct AmbientSignals {
/// completa multimedia). El paisaje **cede**: no se pisa con lo que el usuario
/// ya está escuchando.
pub media_activo: bool,
+ /// **La hebra de la melodía infinita.** Un contador que el adaptador avanza
+ /// cada tanto —al cerrarse una vuelta del bucle— para que la pieza **siga**
+ /// en vez de repetirse idéntica para siempre.
+ ///
+ /// Entra sólo en la [`clave_musical`], que es la semilla: con la misma hora,
+ /// el mismo contexto y la misma app, subir la hebra da **otra melodía del
+ /// mismo color** (igual mood, igual escala, igual tempo). Sin esto el paisaje
+ /// es un loop en repeat, que es lo que se escucha a los diez minutos.
+ pub hebra: u64,
}
impl Default for AmbientSignals {
@@ -55,6 +64,7 @@ impl Default for AmbientSignals {
apps: Vec::new(),
actividad: 0.0,
media_activo: false,
+ hebra: 0,
}
}
}
@@ -231,6 +241,9 @@ pub fn clave_musical(s: &AmbientSignals) -> u64 {
mezclar((s.actividad.clamp(0.0, 1.0) * 4.0).round() as u64 + 100);
// ¿Escritorio cargado? (umbral de percusión) también parte la pieza.
mezclar(if s.apps.len() >= 6 { 7 } else { 3 });
+ // La hebra: lo único que cambia sin que cambie el escritorio. Es lo que
+ // hace que la melodía SIGA en vez de repetirse (ver `AmbientSignals::hebra`).
+ mezclar(s.hebra ^ 0x9e37_79b9_7f4a_7c15);
h | 1
}
@@ -295,6 +308,40 @@ mod tests {
AmbientSignals { hora, ..Default::default() }
}
+ /// **La hebra cambia la melodía sin cambiar el color.** Es la propiedad que
+ /// sostiene todo: si tocara el mood o el tempo, subir la hebra sonaría a otra
+ /// pieza y el paisaje daría saltos cada vez que se renueva.
+ #[test]
+ fn otra_hebra_es_otra_melodia_del_mismo_color() {
+ let mut a = señales(15.0);
+ a.contexto = Some("trabajo".into());
+ a.apps = vec!["kitty".into()];
+ let mut b = a.clone();
+ b.hebra = 1;
+
+ let (ba, bb) = (brief_from(&a), brief_from(&b));
+ assert_ne!(ba.seed, bb.seed, "la hebra tiene que cambiar la semilla");
+ assert_eq!(ba.mood, bb.mood);
+ assert_eq!(ba.scale, bb.scale);
+ assert_eq!(ba.root, bb.root);
+ assert_eq!(ba.style, bb.style);
+ assert_eq!(ba.bars, bb.bars);
+ assert_eq!(ba.parts.bateria, bb.parts.bateria);
+ assert!((ba.tempo_bpm - bb.tempo_bpm).abs() < f32::EPSILON);
+ }
+
+ /// Y el director tiene que **querer** regenerar cuando la hebra avanza: si
+ /// devolviera SinCambio, el contador subiría y la música seguiría igual.
+ #[test]
+ fn subir_la_hebra_pide_regenerar() {
+ let mut sc = Soundscape::new();
+ let mut s = señales(15.0);
+ assert!(matches!(sc.update(&s), Accion::Regenerar(_)));
+ assert!(matches!(sc.update(&s), Accion::SinCambio), "sin cambios, no se toca");
+ s.hebra += 1;
+ assert!(matches!(sc.update(&s), Accion::Regenerar(_)), "la hebra renueva la pieza");
+ }
+
#[test]
fn franjas_del_dia() {
assert_eq!(Franja::from_hora(3.0), Franja::Madrugada);
diff --git a/02_ruway/takiy/takiy-app-llimphi/Cargo.toml b/02_ruway/takiy/takiy-app-llimphi/Cargo.toml
index 206006e..ae862e6 100644
--- a/02_ruway/takiy/takiy-app-llimphi/Cargo.toml
+++ b/02_ruway/takiy/takiy-app-llimphi/Cargo.toml
@@ -28,8 +28,11 @@ name = "escucha_ui_demo"
path = "examples/escucha_ui_demo.rs"
[dependencies]
+# Los textos de pantalla salen del catálogo de idioma, no del fuente.
+rimay-localize = { workspace = true }
takiy-core = { path = "../takiy-core" }
takiy-genesis = { path = "../takiy-genesis" }
+takiy-acordes = { path = "../takiy-acordes" }
takiy-genesis-llm = { path = "../takiy-genesis-llm" }
takiy-escucha = { path = "../takiy-escucha" }
takiy-escucha-llm = { path = "../takiy-escucha-llm" }
@@ -44,7 +47,7 @@ llimphi-widget-menubar = { workspace = true }
llimphi-widget-context-menu = { workspace = true }
llimphi-widget-toolbar = { workspace = true }
llimphi-widget-dock-rail = { workspace = true }
-llimphi-widget-rag-sidebar = { workspace = true }
+llimphi-widget-rag-sidebar = { workspace = true, features = ["std"] }
llimphi-widget-panel = { workspace = true }
llimphi-widget-splitter = { workspace = true }
llimphi-widget-slider = { workspace = true }
@@ -53,7 +56,7 @@ llimphi-widget-segmented = { workspace = true }
llimphi-widget-text-input = { workspace = true }
llimphi-widget-button = { workspace = true }
llimphi-icons = { workspace = true }
-llimphi-widget-toast = { workspace = true }
+llimphi-widget-toast = { workspace = true, features = ["std"] }
llimphi-widget-empty = { workspace = true }
llimphi-motion = { workspace = true }
app-bus = { workspace = true }
diff --git a/02_ruway/takiy/takiy-app-llimphi/examples/pantallazo_takiy.rs b/02_ruway/takiy/takiy-app-llimphi/examples/pantallazo_takiy.rs
index 54e63c8..4641928 100644
--- a/02_ruway/takiy/takiy-app-llimphi/examples/pantallazo_takiy.rs
+++ b/02_ruway/takiy/takiy-app-llimphi/examples/pantallazo_takiy.rs
@@ -19,6 +19,8 @@
// bin-only del crate: los incluimos por `#[path]` para montar exactamente
// la misma view que la app (menubar + toolbar + rails de dientes +
// paneles + canvas).
+#[path = "../src/acordes.rs"]
+mod acordes;
#[path = "../src/msg.rs"]
mod msg;
#[path = "../src/paint.rs"]
@@ -41,6 +43,8 @@ mod proyecto;
mod genesis;
#[path = "../src/escucha.rs"]
mod escucha;
+#[path = "../src/inicio.rs"]
+mod inicio;
#[path = "../src/instr.rs"]
mod instr;
#[path = "../src/secedit.rs"]
@@ -300,6 +304,7 @@ fn proyecto_demo() -> takiy_proyecto::Proyecto {
fn model_demo(theme: Theme) -> Model {
Model {
+ acordes: crate::acordes::Estado::default(),
editor: editor_demo(),
source: "demo built-in".to_string(),
theme,
@@ -353,6 +358,7 @@ fn model_demo(theme: Theme) -> Model {
llm_model: None,
wavimport: None,
escucha: None,
+ inicio: inicio::InicioForm::default(),
}
}
@@ -500,6 +506,31 @@ fn overview_view(model: &Model, theme: Theme) -> View<Msg> {
.children(vec![menubar, toolbar, body])
}
+/// Pantalla Acordes montada con el `acordes::body` real: menubar +
+/// toolbar + paleta de la tonalidad, cinta de acordes, grilla del patrón
+/// y transporte.
+fn acordes_view(model: &Model, theme: Theme) -> View<Msg> {
+ let menu = app_menu();
+ let menubar = menubar_view(&MenuBarSpec {
+ menu: &menu,
+ open: None,
+ theme: &theme,
+ viewport: (W as f32, H as f32),
+ height: MENU_H,
+ on_open: Arc::new(Msg::MenuOpen),
+ on_command: Arc::new(|c: &str| Msg::MenuCommand(c.to_string())),
+ });
+ let toolbar = chrome::toolbar_bar(model, &theme);
+ let body = acordes::body(&model.acordes, &theme, model.playing);
+ View::new(Style {
+ flex_direction: FlexDirection::Column,
+ size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
+ ..Default::default()
+ })
+ .fill(theme.bg_app)
+ .children(vec![menubar, toolbar, body])
+}
+
/// Editor de onda montado con el `waveedit::body` real: menubar +
/// toolbar (con «‹ pistas») + barra de ops + forma de onda con una
/// selección y ediciones aplicadas (silencio + fade out).
@@ -594,6 +625,21 @@ fn main() {
model.onda_peaks.insert(0, peaks);
model.wave_sel = Some((4.0, 6.0));
waveedit_view(&model, theme)
+ } else if std::env::var_os("TAKIY_ACORDES").is_some() {
+ // La pantalla Acordes con una cadencia cargada y el rasgueo pop
+ // abierto en la grilla — lo que ve alguien pensando una canción.
+ model.screen = appmodel::Screen::Acordes;
+ model.acordes = acordes::Estado::default();
+ acordes::aplicar(&mut model.acordes, acordes::MsgAcordes::Cadencia(0));
+ acordes::aplicar(&mut model.acordes, acordes::MsgAcordes::Seleccionar(2));
+ acordes::aplicar(&mut model.acordes, acordes::MsgAcordes::AbrirPatron(2));
+ // `TAKIY_ACORDES_LIBRE=1` muestra la paleta libre (12 raíces ×
+ // cualidades) en vez de los siete grados de la tonalidad.
+ if std::env::var_os("TAKIY_ACORDES_LIBRE").is_some() {
+ acordes::aplicar(&mut model.acordes, acordes::MsgAcordes::Matriz);
+ acordes::aplicar(&mut model.acordes, acordes::MsgAcordes::Cualidad(11));
+ }
+ acordes_view(&model, theme)
} else if std::env::var_os("TAKIY_OVERVIEW").is_some() {
model.screen = appmodel::Screen::Overview;
// Pistas pares en onda (con picos), impares en midi.
@@ -649,7 +695,7 @@ fn main() {
..Default::default()
})
.children(vec![base, ov])
- } else if std::env::var_os("TAKIY_VOZ").is_some() {
+ } else if std::env::var_os("TAKIY_VOICE").is_some() {
model.screen = appmodel::Screen::Overview;
voz_view(&model, theme)
} else if std::env::var_os("TAKIY_INSTRIA").is_some() {
@@ -687,7 +733,7 @@ fn main() {
..Default::default()
})
.children(vec![base, ov])
- } else if std::env::var_os("TAKIY_VOZSLIDERS").is_some() {
+ } else if std::env::var_os("TAKIY_VOICESLIDERS").is_some() {
// Editor con el panel Tonalidad abierto y una toma de voz activa:
// se ven los sliders finos de tono/formante con sus valores.
model.screen = appmodel::Screen::Track;
@@ -707,7 +753,7 @@ fn main() {
let idx = model.editor.score.add_track(t);
model.editor.active_track = idx;
view_demo(&model, theme)
- } else if std::env::var_os("TAKIY_VOZFX").is_some() {
+ } else if std::env::var_os("TAKIY_VOICEFX").is_some() {
// Pista de voz activa con un clip real → aparece la barra de
// modificación de voz (transponer / afinar) y la onda del clip.
model.screen = appmodel::Screen::Overview;
diff --git a/02_ruway/takiy/takiy-app-llimphi/src/acordes.rs b/02_ruway/takiy/takiy-app-llimphi/src/acordes.rs
new file mode 100644
index 0000000..e018164
--- /dev/null
+++ b/02_ruway/takiy/takiy-app-llimphi/src/acordes.rs
@@ -0,0 +1,912 @@
+//! Pantalla **Acordes** — pensar una canción desde los acordes y el ritmo.
+//!
+//! Es el frente Llimphi de `takiy-acordes`: la paleta de la tonalidad, la
+//! cinta de acordes, el editor de patrón rítmico y el transporte. Toda la
+//! música vive en el crate agnóstico; acá sólo se pinta y se despacha.
+//!
+//! El flujo es el de alguien con una guitarra: se elige tonalidad, se
+//! sueltan acordes en la cinta (o se tira una cadencia entera), se les
+//! pone un rasgueo, se escucha en loop, y cuando la idea está, «al piano
+//! roll» la abre como proyecto para seguirla nota por nota.
+
+use llimphi_theme::Theme;
+use llimphi_ui::llimphi_layout::taffy::{
+ prelude::{length, percent, Dimension, FlexDirection, Size, Style},
+ AlignItems, JustifyContent, Rect,
+};
+use llimphi_ui::llimphi_text::Alignment;
+use llimphi_ui::View;
+use llimphi_widget_segmented::{segmented_view, SegmentedPalette};
+
+use takiy_acordes::paleta::{TipoTonalidad, CUALIDADES, RAICES};
+use takiy_acordes::progresion::{Ranura, CADENCIAS};
+use takiy_acordes::{Banco, Cancion, Paleta, Patron, Progresion};
+use takiy_core::{Chord, PitchClass, Score};
+
+use crate::genesis::{chip, dialog_row, label_cell, pill_button, stepper, text_grow};
+use crate::msg::Msg;
+use rimay_localize::t;
+
+/// Estado de la pantalla. La canción es el dato; el resto es qué está
+/// mirando el usuario.
+pub(crate) struct Estado {
+ pub(crate) cancion: Cancion,
+ pub(crate) banco: Banco,
+ /// Ranura seleccionada de la cinta.
+ pub(crate) sel: usize,
+ /// Patrón abierto en el editor de abajo.
+ pub(crate) patron: usize,
+ /// La paleta libre (12 raíces × cualidades) en vez de los siete grados.
+ pub(crate) matriz: bool,
+ /// Cualidad elegida en la paleta libre.
+ pub(crate) cualidad: usize,
+}
+
+impl Default for Estado {
+ fn default() -> Self {
+ let cancion = Cancion::de_arranque(Paleta::default());
+ let banco = cancion.banco();
+ let patron = cancion.ajustes.patron;
+ Self { cancion, banco, sel: 0, patron, matriz: false, cualidad: 0 }
+ }
+}
+
+impl Estado {
+ /// La canción hecha `Score`, con el banco vivo de la sesión (que puede
+ /// tener patrones propios que la canción todavía no absorbió).
+ pub(crate) fn score(&self) -> Score {
+ takiy_acordes::render(&self.cancion.progresion, &self.banco, &self.cancion.ajustes)
+ }
+
+ fn paleta(&self) -> Paleta {
+ self.cancion.paleta
+ }
+
+ /// La ranura seleccionada, si la cinta no está vacía.
+ fn ranura(&self) -> Option<&Ranura> {
+ self.cancion.progresion.ranura(self.sel)
+ }
+
+ /// El patrón que se está editando, ya garantizado propio (si era de
+ /// fábrica lo copia y deja el editor sobre la copia).
+ fn hacer_mio(&mut self) -> usize {
+ let i = self.banco.editar(self.patron);
+ self.patron = i;
+ i
+ }
+
+ /// Resumen de una línea para la barra de estado.
+ pub(crate) fn resumen(&self) -> String {
+ let p = &self.cancion.progresion;
+ let nombres: Vec<String> = p.ranuras().iter().map(|r| r.nombre()).collect();
+ format!(
+ "{} · {} · {} bpm · {}",
+ self.cancion.paleta.nombre(),
+ self.banco.get_o_primero(self.cancion.ajustes.patron).nombre,
+ self.cancion.ajustes.bpm as i32,
+ if nombres.is_empty() { t("takiy-no-chords") } else { nombres.join(" ") }
+ )
+ }
+}
+
+/// Lo que puede pasar en la pantalla de acordes.
+#[derive(Debug, Clone, Copy, PartialEq)]
+pub(crate) enum MsgAcordes {
+ /// Tónica de la paleta (índice 0..11 en el orden cromático).
+ Tonica(usize),
+ /// 0 = mayor, 1 = menor.
+ Modo(usize),
+ Septimas,
+ /// Suelta una cadencia entera del catálogo, reemplazando la cinta.
+ Cadencia(usize),
+ /// Agrega el acorde del grado `n` de la tonalidad.
+ Grado(usize),
+ /// Alterna paleta por grados ↔ paleta libre.
+ Matriz,
+ /// Cualidad elegida en la paleta libre.
+ Cualidad(usize),
+ /// Agrega el acorde `raíz × cualidad elegida` de la paleta libre.
+ Raiz(usize),
+ Seleccionar(usize),
+ Quitar,
+ Duplicar,
+ Mover(i32),
+ LargoDelta(f32),
+ OctavaDelta(i32),
+ InversionDelta(i32),
+ /// Ciclo del patrón de la ranura: el de la canción → cada uno del banco.
+ PatronRanura,
+ /// Patrón por defecto de la canción (el que usa toda ranura sin propio).
+ PatronCancion(i32),
+ /// Abre un patrón en el editor de abajo.
+ AbrirPatron(usize),
+ /// Crea un patrón propio vacío y lo abre — arrancar de cero.
+ PatronVacio,
+ /// Click en una celda de la grilla: cicla el golpe.
+ Celda(usize),
+ Pasos(usize),
+ Rotar(i32),
+ Limpiar,
+ HacerMio,
+ RasgueoDelta(f32),
+ BpmDelta(f32),
+ /// Tiempos por compás (4 o 3).
+ Compas(f32),
+ SwingDelta(f32),
+ Bajo,
+ Bateria,
+ Transponer(i32),
+ Repetir,
+ /// Toca la progresión en loop (o la detiene si ya sonaba).
+ Tocar,
+ /// Vuelca la canción al piano roll como proyecto nuevo.
+ AlPianoRoll,
+}
+
+// =====================================================================
+// Vista
+// =====================================================================
+
+pub(crate) fn body(estado: &Estado, theme: &Theme, playing: bool) -> View<Msg> {
+ let seg = SegmentedPalette::from_theme(theme);
+ let mut filas: Vec<View<Msg>> = Vec::new();
+
+ // ── Tonalidad y compás ────────────────────────────────────────────
+ let raices: Vec<&str> = RAICES.iter().map(|r| r.name()).collect();
+ filas.push(dialog_row(vec![
+ label_cell(theme, "Tonalidad"),
+ segmented_view(
+ &raices,
+ estado.paleta().tonica.semitone() as usize,
+ |i| Msg::Acordes(MsgAcordes::Tonica(i)),
+ &seg,
+ ),
+ ]));
+ filas.push(dialog_row(vec![
+ label_cell(theme, "Modo"),
+ // El segmentado pide todo el ancho que le den, así que va en una
+ // caja de ancho fijo: si no, empuja a los chips fuera de pantalla.
+ caja(240.0, segmented_view(
+ &["mayor", "menor"],
+ matches!(estado.paleta().tipo, TipoTonalidad::Menor) as usize,
+ |i| Msg::Acordes(MsgAcordes::Modo(i)),
+ &seg,
+ )),
+ chip(theme, &t("takiy-with-sevenths"), estado.paleta().septimas, Msg::Acordes(MsgAcordes::Septimas)),
+ chip(theme, "paleta libre", estado.matriz, Msg::Acordes(MsgAcordes::Matriz)),
+ text_grow("", 11.0, theme.fg_muted),
+ ]));
+
+ // ── Paleta: los siete grados, o la matriz libre ───────────────────
+ if estado.matriz {
+ let cual: Vec<&str> = CUALIDADES.iter().map(|q| etiqueta_cualidad(*q)).collect();
+ // Las cualidades no entran en una fila: van en dos. La selección
+ // vive en una sola, así que a la otra se le pasa un índice fuera
+ // de rango — si no, las dos filas se pintarían con un elegido.
+ let sel = estado.cualidad.min(CUALIDADES.len() - 1);
+ filas.push(dialog_row(vec![
+ label_cell(theme, "Cualidad"),
+ segmented_view(
+ &cual[..CORTE_CUALIDADES],
+ sel_en_fila(sel, 0, CORTE_CUALIDADES),
+ |i| Msg::Acordes(MsgAcordes::Cualidad(i)),
+ &seg,
+ ),
+ ]));
+ filas.push(dialog_row(vec![
+ label_cell(theme, ""),
+ segmented_view(
+ &cual[CORTE_CUALIDADES..],
+ sel_en_fila(sel, CORTE_CUALIDADES, CUALIDADES.len()),
+ |i| Msg::Acordes(MsgAcordes::Cualidad(i + CORTE_CUALIDADES)),
+ &seg,
+ ),
+ ]));
+ let q = CUALIDADES[estado.cualidad.min(CUALIDADES.len() - 1)];
+ let celdas: Vec<View<Msg>> = RAICES
+ .iter()
+ .enumerate()
+ .map(|(i, &r)| {
+ let ch = Chord::new(r, q);
+ boton_acorde(
+ theme,
+ &ch.name(),
+ estado.paleta().grado_de(ch).map(|d| grado_romano(&estado.paleta(), d)),
+ estado.paleta().es_diatonico(ch),
+ false,
+ Msg::Acordes(MsgAcordes::Raiz(i)),
+ )
+ })
+ .collect();
+ filas.push(fila_alta(46.0, celdas));
+ } else {
+ let celdas: Vec<View<Msg>> = estado
+ .paleta()
+ .grados()
+ .into_iter()
+ .enumerate()
+ .map(|(i, (romano, ch))| {
+ boton_acorde(
+ theme,
+ &ch.name(),
+ Some(romano),
+ true,
+ false,
+ Msg::Acordes(MsgAcordes::Grado(i)),
+ )
+ })
+ .collect();
+ filas.push(fila_alta(46.0, celdas));
+ }
+
+ // ── Cadencias listas ──────────────────────────────────────────────
+ let mut cadencias: Vec<View<Msg>> = vec![label_cell(theme, "Cadencias")];
+ cadencias.extend(CADENCIAS.iter().enumerate().take(8).map(|(i, (nombre, _))| {
+ chip(theme, nombre, false, Msg::Acordes(MsgAcordes::Cadencia(i)))
+ }));
+ filas.push(dialog_row(cadencias));
+
+ // ── La cinta de acordes ───────────────────────────────────────────
+ let prog = &estado.cancion.progresion;
+ let mut cinta: Vec<View<Msg>> = prog
+ .ranuras()
+ .iter()
+ .enumerate()
+ .map(|(i, r)| {
+ let sub = match r.patron {
+ Some(p) => estado.banco.get_o_primero(p).nombre.clone(),
+ None => format!("{} compás", fmt_compases(r.compases)),
+ };
+ boton_acorde(
+ theme,
+ &r.nombre(),
+ Some(sub),
+ true,
+ i == estado.sel,
+ Msg::Acordes(MsgAcordes::Seleccionar(i)),
+ )
+ })
+ .collect();
+ if cinta.is_empty() {
+ cinta.push(text_grow(&t("takiy-tape-empty"), 12.0, theme.fg_muted));
+ }
+ filas.push(fila_alta(52.0, cinta));
+
+ // ── La ranura seleccionada ────────────────────────────────────────
+ if let Some(r) = estado.ranura() {
+ let patron_lbl = match r.patron {
+ Some(p) => estado.banco.get_o_primero(p).nombre.clone(),
+ None => t("takiy-from-song"),
+ };
+ filas.push(dialog_row(vec![
+ label_cell(theme, &r.nombre()),
+ stepper(
+ theme,
+ &fmt_compases(r.compases),
+ Msg::Acordes(MsgAcordes::LargoDelta(-0.5)),
+ Msg::Acordes(MsgAcordes::LargoDelta(0.5)),
+ ),
+ label_cell(theme, "octava"),
+ stepper(
+ theme,
+ &r.octava.to_string(),
+ Msg::Acordes(MsgAcordes::OctavaDelta(-1)),
+ Msg::Acordes(MsgAcordes::OctavaDelta(1)),
+ ),
+ label_cell(theme, &t("takiy-inversion")),
+ stepper(
+ theme,
+ &r.inversion.to_string(),
+ Msg::Acordes(MsgAcordes::InversionDelta(-1)),
+ Msg::Acordes(MsgAcordes::InversionDelta(1)),
+ ),
+ chip(theme, &format!("ritmo: {patron_lbl}"), r.patron.is_some(), Msg::Acordes(MsgAcordes::PatronRanura)),
+ chip(theme, "◀", false, Msg::Acordes(MsgAcordes::Mover(-1))),
+ chip(theme, "▶", false, Msg::Acordes(MsgAcordes::Mover(1))),
+ chip(theme, "duplicar", false, Msg::Acordes(MsgAcordes::Duplicar)),
+ chip(theme, "quitar", false, Msg::Acordes(MsgAcordes::Quitar)),
+ ]));
+ }
+
+ // ── El patrón: banco arriba, grilla abajo ─────────────────────────
+ let patron = estado.banco.get_o_primero(estado.patron);
+ let mut banco_fila: Vec<View<Msg>> = vec![label_cell(theme, "Ritmo")];
+ banco_fila.extend(estado.banco.patrones().iter().enumerate().map(|(i, p)| {
+ chip(theme, &p.nombre, i == estado.patron, Msg::Acordes(MsgAcordes::AbrirPatron(i)))
+ }));
+ banco_fila.push(chip(theme, &t("takiy-empty-slot"), false, Msg::Acordes(MsgAcordes::PatronVacio)));
+ filas.push(dialog_row(banco_fila));
+
+ let celdas: Vec<View<Msg>> = (0..patron.pasos_por_compas())
+ .map(|i| {
+ let paso = patron.paso(i).copied().unwrap_or_default();
+ celda_paso(theme, &paso.golpe.simbolo(), paso.golpe.suena(), i % 4 == 0, Msg::Acordes(MsgAcordes::Celda(i)))
+ })
+ .collect();
+ filas.push(fila_alta(38.0, celdas));
+
+ filas.push(dialog_row(vec![
+ label_cell(theme, &if estado.banco.es_fijo(estado.patron) { t("takiy-factory") } else { t("takiy-mine") }),
+ chip(theme, "4", patron.pasos_por_compas() == 4, Msg::Acordes(MsgAcordes::Pasos(4))),
+ chip(theme, "6", patron.pasos_por_compas() == 6, Msg::Acordes(MsgAcordes::Pasos(6))),
+ chip(theme, "8", patron.pasos_por_compas() == 8, Msg::Acordes(MsgAcordes::Pasos(8))),
+ chip(theme, "16", patron.pasos_por_compas() == 16, Msg::Acordes(MsgAcordes::Pasos(16))),
+ chip(theme, "rotar ◀", false, Msg::Acordes(MsgAcordes::Rotar(-1))),
+ chip(theme, "rotar ▶", false, Msg::Acordes(MsgAcordes::Rotar(1))),
+ chip(theme, "limpiar", false, Msg::Acordes(MsgAcordes::Limpiar)),
+ chip(theme, &t("takiy-make-mine"), false, Msg::Acordes(MsgAcordes::HacerMio)),
+ label_cell(theme, "rasgueo"),
+ stepper(
+ theme,
+ &format!("{:.2}", patron.ancho_rasgueo),
+ Msg::Acordes(MsgAcordes::RasgueoDelta(-0.04)),
+ Msg::Acordes(MsgAcordes::RasgueoDelta(0.04)),
+ ),
+ chip(theme, "usarlo en toda la canción", estado.cancion.ajustes.patron == estado.patron, Msg::Acordes(MsgAcordes::PatronCancion(0))),
+ ]));
+
+ // ── Ajustes y transporte ──────────────────────────────────────────
+ let aj = &estado.cancion.ajustes;
+ filas.push(dialog_row(vec![
+ label_cell(theme, "Tempo"),
+ stepper(
+ theme,
+ &format!("{}", aj.bpm as i32),
+ Msg::Acordes(MsgAcordes::BpmDelta(-4.0)),
+ Msg::Acordes(MsgAcordes::BpmDelta(4.0)),
+ ),
+ label_cell(theme, &t("takiy-bar")),
+ chip(theme, "4/4", (aj.beats_por_compas - 4.0).abs() < 0.01, Msg::Acordes(MsgAcordes::Compas(4.0))),
+ chip(theme, "3/4", (aj.beats_por_compas - 3.0).abs() < 0.01, Msg::Acordes(MsgAcordes::Compas(3.0))),
+ label_cell(theme, "swing"),
+ stepper(
+ theme,
+ &format!("{:.0}%", aj.swing * 100.0),
+ Msg::Acordes(MsgAcordes::SwingDelta(-0.05)),
+ Msg::Acordes(MsgAcordes::SwingDelta(0.05)),
+ ),
+ chip(theme, "bajo", aj.bajo, Msg::Acordes(MsgAcordes::Bajo)),
+ chip(theme, &t("takiy-drums"), aj.bateria, Msg::Acordes(MsgAcordes::Bateria)),
+ chip(theme, "−1 tono", false, Msg::Acordes(MsgAcordes::Transponer(-2))),
+ chip(theme, "+1 tono", false, Msg::Acordes(MsgAcordes::Transponer(2))),
+ chip(theme, "×2", false, Msg::Acordes(MsgAcordes::Repetir)),
+ ]));
+
+ filas.push(dialog_row(vec![
+ pill_button(
+ theme,
+ if playing { "■ detener" } else { "▶ tocar en loop" },
+ Msg::Acordes(MsgAcordes::Tocar),
+ !playing,
+ ),
+ pill_button(theme, &t("takiy-to-piano-roll"), Msg::Acordes(MsgAcordes::AlPianoRoll), false),
+ text_grow(&estado.resumen(), 11.0, theme.fg_muted),
+ ]));
+
+ View::new(Style {
+ flex_direction: FlexDirection::Column,
+ flex_grow: 1.0,
+ size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
+ padding: Rect {
+ left: length(10.0_f32),
+ right: length(10.0_f32),
+ top: length(8.0_f32),
+ bottom: length(8.0_f32),
+ },
+ gap: Size { width: length(0.0_f32), height: length(6.0_f32) },
+ ..Default::default()
+ })
+ .fill(theme.bg_app)
+ .children(filas)
+}
+
+/// Dónde se parte la lista de cualidades en dos filas de segmentado.
+const CORTE_CUALIDADES: usize = 9;
+
+/// Índice a marcar como elegido dentro de la fila `[desde, hasta)`. Si la
+/// selección global cae en la otra fila devuelve un índice imposible: un
+/// segmentado marca `i == selected`, así que fuera de rango = ninguno
+/// elegido. Sin esto las dos filas se pintan con un elegido cada una.
+fn sel_en_fila(sel: usize, desde: usize, hasta: usize) -> usize {
+ if sel >= desde && sel < hasta {
+ sel - desde
+ } else {
+ usize::MAX
+ }
+}
+
+/// Encierra un control en una caja de ancho fijo. Los segmentados crecen
+/// hasta el ancho que se les dé; sin esto, empujan a sus vecinos afuera.
+fn caja(w: f32, hijo: View<Msg>) -> View<Msg> {
+ View::new(Style {
+ size: Size { width: length(w), height: length(28.0_f32) },
+ flex_shrink: 0.0,
+ align_items: Some(AlignItems::CENTER),
+ ..Default::default()
+ })
+ .children(vec![hijo])
+}
+
+/// Una fila de celdas iguales que se reparten el ancho.
+fn fila_alta(h: f32, hijos: Vec<View<Msg>>) -> View<Msg> {
+ View::new(Style {
+ flex_direction: FlexDirection::Row,
+ size: Size { width: percent(1.0_f32), height: length(h) },
+ flex_shrink: 0.0,
+ align_items: Some(AlignItems::CENTER),
+ gap: Size { width: length(4.0_f32), height: length(0.0_f32) },
+ ..Default::default()
+ })
+ .children(hijos)
+}
+
+/// Botón grande de acorde: el nombre arriba, el número romano (o el largo)
+/// abajo. Marca con el acento los que son de la tonalidad.
+fn boton_acorde(
+ theme: &Theme,
+ nombre: &str,
+ sub: Option<String>,
+ diatonico: bool,
+ seleccionado: bool,
+ msg: Msg,
+) -> View<Msg> {
+ let (bg, fg) = if seleccionado {
+ (theme.accent, theme.bg_app)
+ } else if diatonico {
+ (theme.bg_panel, theme.fg_text)
+ } else {
+ (theme.bg_app, theme.fg_muted)
+ };
+ let mut hijos = vec![View::new(Style {
+ size: Size { width: percent(1.0_f32), height: length(18.0_f32) },
+ ..Default::default()
+ })
+ .text_aligned(nombre.to_string(), 14.0, fg, Alignment::Center)];
+ if let Some(s) = sub {
+ hijos.push(
+ View::new(Style {
+ size: Size { width: percent(1.0_f32), height: length(14.0_f32) },
+ ..Default::default()
+ })
+ .text_aligned(s, 10.0, if seleccionado { theme.bg_app } else { theme.fg_muted }, Alignment::Center),
+ );
+ }
+ View::new(Style {
+ flex_direction: FlexDirection::Column,
+ flex_grow: 1.0,
+ size: Size { width: percent(0.0_f32), height: percent(1.0_f32) },
+ align_items: Some(AlignItems::CENTER),
+ justify_content: Some(JustifyContent::CENTER),
+ ..Default::default()
+ })
+ .fill(bg)
+ .radius(6.0)
+ .hover_fill(theme.bg_row_hover)
+ .on_click(msg)
+ .children(hijos)
+}
+
+/// Celda de la grilla del patrón. Los pasos que caen en tiempo fuerte se
+/// pintan un poco más claros para no perder la cuenta.
+fn celda_paso(theme: &Theme, simbolo: &str, suena: bool, fuerte: bool, msg: Msg) -> View<Msg> {
+ let bg = if suena {
+ theme.accent
+ } else if fuerte {
+ theme.bg_panel
+ } else {
+ theme.bg_app
+ };
+ let fg = if suena { theme.bg_app } else { theme.fg_muted };
+ View::new(Style {
+ flex_grow: 1.0,
+ size: Size { width: percent(0.0_f32), height: percent(1.0_f32) },
+ align_items: Some(AlignItems::CENTER),
+ justify_content: Some(JustifyContent::CENTER),
+ ..Default::default()
+ })
+ .fill(bg)
+ .radius(4.0)
+ .hover_fill(theme.bg_row_hover)
+ .on_click(msg)
+ .children(vec![View::new(Style {
+ size: Size { width: Dimension::auto(), height: Dimension::auto() },
+ ..Default::default()
+ })
+ .text_aligned(simbolo.to_string(), 13.0, fg, Alignment::Center)])
+}
+
+fn fmt_compases(c: f32) -> String {
+ if (c - c.round()).abs() < 0.01 {
+ format!("{}", c.round() as i32)
+ } else {
+ format!("{c:.1}")
+ }
+}
+
+fn grado_romano(paleta: &Paleta, deg: usize) -> String {
+ takiy_acordes::paleta::romano(deg, paleta.grado(deg).quality)
+}
+
+fn etiqueta_cualidad(q: takiy_core::ChordQuality) -> &'static str {
+ match q.suffix() {
+ "" => "may",
+ s => s,
+ }
+}
+
+// =====================================================================
+// Actualización
+// =====================================================================
+
+/// Aplica un mensaje al estado. Devuelve el `Score` a tocar cuando el
+/// mensaje pide sonido — el caller (que es quien tiene el player) decide
+/// qué hacer con él.
+pub(crate) fn aplicar(estado: &mut Estado, msg: MsgAcordes) -> Sonar {
+ match msg {
+ MsgAcordes::Tonica(i) => {
+ estado.cancion.paleta.tonica = PitchClass::from_semitone(i as u8 % 12);
+ }
+ MsgAcordes::Modo(i) => {
+ estado.cancion.paleta.tipo =
+ if i == 1 { TipoTonalidad::Menor } else { TipoTonalidad::Mayor };
+ }
+ MsgAcordes::Septimas => {
+ estado.cancion.paleta.septimas = !estado.cancion.paleta.septimas;
+ }
+ MsgAcordes::Cadencia(i) => {
+ let paleta = estado.paleta();
+ estado.cancion.progresion = Progresion::cadencia(&paleta, i);
+ estado.sel = 0;
+ return Sonar::Ranura(0);
+ }
+ MsgAcordes::Grado(d) => {
+ let ch = estado.paleta().grado(d);
+ estado.sel = estado.cancion.progresion.agregar(ch);
+ return Sonar::Ranura(estado.sel);
+ }
+ MsgAcordes::Matriz => estado.matriz = !estado.matriz,
+ MsgAcordes::Cualidad(i) => estado.cualidad = i.min(CUALIDADES.len() - 1),
+ MsgAcordes::Raiz(i) => {
+ let q = CUALIDADES[estado.cualidad.min(CUALIDADES.len() - 1)];
+ let ch = Chord::new(RAICES[i % 12], q);
+ estado.sel = estado.cancion.progresion.agregar(ch);
+ return Sonar::Ranura(estado.sel);
+ }
+ MsgAcordes::Seleccionar(i) => {
+ estado.sel = i;
+ return Sonar::Ranura(i);
+ }
+ MsgAcordes::Quitar => {
+ estado.cancion.progresion.quitar(estado.sel);
+ estado.sel = estado.sel.min(estado.cancion.progresion.len().saturating_sub(1));
+ }
+ MsgAcordes::Duplicar => {
+ if let Some(i) = estado.cancion.progresion.duplicar(estado.sel) {
+ estado.sel = i;
+ }
+ }
+ MsgAcordes::Mover(d) => {
+ let destino = estado.sel as i32 + d;
+ if destino >= 0 && estado.cancion.progresion.mover(estado.sel, destino as usize) {
+ estado.sel = destino as usize;
+ }
+ }
+ MsgAcordes::LargoDelta(d) => {
+ if let Some(r) = estado.cancion.progresion.ranura_mut(estado.sel) {
+ r.compases = (r.compases + d).clamp(0.5, 8.0);
+ }
+ }
+ MsgAcordes::OctavaDelta(d) => {
+ if let Some(r) = estado.cancion.progresion.ranura_mut(estado.sel) {
+ r.octava = (r.octava + d).clamp(1, 7);
+ }
+ return Sonar::Ranura(estado.sel);
+ }
+ MsgAcordes::InversionDelta(d) => {
+ if let Some(r) = estado.cancion.progresion.ranura_mut(estado.sel) {
+ let n = r.acorde.voice_count() as i32 - 1;
+ r.inversion = (r.inversion as i32 + d).clamp(0, n.max(0)) as u8;
+ }
+ return Sonar::Ranura(estado.sel);
+ }
+ MsgAcordes::PatronRanura => {
+ let n = estado.banco.len();
+ let actual = estado.cancion.progresion.ranura(estado.sel).and_then(|r| r.patron);
+ let siguiente = match actual {
+ None => Some(0),
+ Some(i) if i + 1 < n => Some(i + 1),
+ Some(_) => None,
+ };
+ if let Some(r) = estado.cancion.progresion.ranura_mut(estado.sel) {
+ r.patron = siguiente;
+ }
+ if let Some(i) = siguiente {
+ estado.patron = i;
+ }
+ }
+ MsgAcordes::PatronCancion(_) => {
+ estado.cancion.ajustes.patron = estado.patron;
+ }
+ MsgAcordes::AbrirPatron(i) => estado.patron = i.min(estado.banco.len() - 1),
+ MsgAcordes::PatronVacio => {
+ estado.patron = estado.banco.agregar(patron_nuevo());
+ }
+ MsgAcordes::Celda(i) => {
+ let p = estado.hacer_mio();
+ estado.banco.con_mut(p, |patron| patron.ciclar(i));
+ }
+ MsgAcordes::Pasos(n) => {
+ let p = estado.hacer_mio();
+ estado.banco.con_mut(p, |patron| patron.redimensionar(n));
+ }
+ MsgAcordes::Rotar(k) => {
+ let p = estado.hacer_mio();
+ estado.banco.con_mut(p, |patron| patron.rotar(k));
+ }
+ MsgAcordes::Limpiar => {
+ let p = estado.hacer_mio();
+ estado.banco.con_mut(p, |patron| patron.limpiar());
+ }
+ MsgAcordes::HacerMio => {
+ estado.hacer_mio();
+ }
+ MsgAcordes::RasgueoDelta(d) => {
+ let p = estado.hacer_mio();
+ estado.banco.con_mut(p, |patron| {
+ patron.ancho_rasgueo = (patron.ancho_rasgueo + d).clamp(0.0, 0.9);
+ });
+ }
+ MsgAcordes::BpmDelta(d) => {
+ estado.cancion.ajustes.bpm = (estado.cancion.ajustes.bpm + d).clamp(30.0, 240.0);
+ }
+ MsgAcordes::Compas(b) => estado.cancion.ajustes.beats_por_compas = b,
+ MsgAcordes::SwingDelta(d) => {
+ estado.cancion.ajustes.swing = (estado.cancion.ajustes.swing + d).clamp(0.0, 0.45);
+ }
+ MsgAcordes::Bajo => estado.cancion.ajustes.bajo = !estado.cancion.ajustes.bajo,
+ MsgAcordes::Bateria => estado.cancion.ajustes.bateria = !estado.cancion.ajustes.bateria,
+ MsgAcordes::Transponer(s) => {
+ estado.cancion.progresion.transponer(s);
+ let t = (estado.cancion.paleta.tonica.semitone() as i32 + s).rem_euclid(12) as u8;
+ estado.cancion.paleta.tonica = PitchClass::from_semitone(t);
+ }
+ MsgAcordes::Repetir => estado.cancion.progresion.repetir(2),
+ MsgAcordes::Tocar => return Sonar::Progresion,
+ MsgAcordes::AlPianoRoll => return Sonar::AlPianoRoll,
+ }
+ Sonar::Nada
+}
+
+/// Lo que el estado le pide al mundo de afuera después de un mensaje.
+#[derive(Debug, Clone, Copy, PartialEq)]
+pub(crate) enum Sonar {
+ Nada,
+ /// Escuchar un acorde suelto de la cinta.
+ Ranura(usize),
+ /// Tocar (o detener) la progresión entera.
+ Progresion,
+ /// Abrir la canción en el piano roll.
+ AlPianoRoll,
+}
+
+/// Un `Score` cortito con las voces de una ranura — el blip de audición al
+/// tocar un acorde. Un solo compás, sin ritmo: es «cómo suena este acorde».
+pub(crate) fn blip_de_ranura(estado: &Estado, i: usize) -> Option<Score> {
+ let ranura = estado.cancion.progresion.ranura(i)?;
+ let voces = takiy_acordes::voces_de(ranura);
+ if voces.is_empty() {
+ return None;
+ }
+ let mut score = Score::new(120.0);
+ let mut track = takiy_core::Track::new("Acordes");
+ for (k, v) in voces.iter().enumerate() {
+ track.add(takiy_core::ScoreNote::new(*v, k as f32 * 0.03, 1.0, 96));
+ }
+ score.add_track(track);
+ Some(score)
+}
+
+/// Patrón vacío nuevo, para arrancar uno propio desde cero.
+pub(crate) fn patron_nuevo() -> Patron {
+ Patron::vacio("Mi ritmo", 8)
+}
+
+// =====================================================================
+// Persistencia
+// =====================================================================
+
+/// Dónde vive la canción de acordes: junto a los proyectos, con su propia
+/// extensión. Es un archivo, no una entrada del DAG de proyectos: lo que
+/// se guarda acá es la *idea* (acordes y ritmos), no la partitura.
+pub(crate) fn ruta(dir: &std::path::Path) -> std::path::PathBuf {
+ dir.join("acordes.takiycancion.json")
+}
+
+/// Lee la canción de disco, o arranca una nueva si no hay (o si el
+/// archivo quedó ilegible — una idea vieja no vale trabar la pantalla).
+pub(crate) fn cargar(dir: &std::path::Path) -> Estado {
+ let Ok(txt) = std::fs::read_to_string(ruta(dir)) else {
+ return Estado::default();
+ };
+ match serde_json::from_str::<Cancion>(&txt) {
+ Ok(cancion) => {
+ let banco = cancion.banco();
+ let patron = cancion.ajustes.patron.min(banco.len() - 1);
+ Estado { cancion, banco, sel: 0, patron, matriz: false, cualidad: 0 }
+ }
+ Err(_) => Estado::default(),
+ }
+}
+
+/// Escribe la canción (con los patrones propios adentro) de forma atómica:
+/// tmp + rename, igual que el score.
+pub(crate) fn guardar(estado: &mut Estado, dir: &std::path::Path) -> std::io::Result<()> {
+ estado.cancion.absorber_propios(&estado.banco);
+ std::fs::create_dir_all(dir)?;
+ let txt = serde_json::to_string_pretty(&estado.cancion)
+ .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
+ let destino = ruta(dir);
+ let tmp = destino.with_extension("json.tmp");
+ std::fs::write(&tmp, txt)?;
+ std::fs::rename(&tmp, &destino)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ /// Aplica una serie de mensajes y devuelve la última orden emitida.
+ fn correr(estado: &mut Estado, msgs: &[MsgAcordes]) -> Sonar {
+ let mut ultima = Sonar::Nada;
+ for m in msgs {
+ ultima = aplicar(estado, *m);
+ }
+ ultima
+ }
+
+ #[test]
+ fn arranca_con_una_cancion_que_ya_suena() {
+ let e = Estado::default();
+ assert_eq!(e.cancion.progresion.len(), 4);
+ assert!(!e.score().tracks()[0].is_empty());
+ assert!(e.resumen().contains("C mayor"));
+ }
+
+ #[test]
+ fn tocar_un_grado_lo_agrega_y_lo_hace_sonar() {
+ let mut e = Estado::default();
+ let antes = e.cancion.progresion.len();
+ let orden = correr(&mut e, &[MsgAcordes::Grado(4)]);
+ assert_eq!(e.cancion.progresion.len(), antes + 1);
+ assert_eq!(e.cancion.progresion.ranura(antes).unwrap().nombre(), "G");
+ assert_eq!(orden, Sonar::Ranura(antes));
+ assert!(blip_de_ranura(&e, antes).is_some());
+ }
+
+ #[test]
+ fn cambiar_de_tonalidad_cambia_la_paleta_pero_no_la_cinta() {
+ let mut e = Estado::default();
+ let antes: Vec<String> =
+ e.cancion.progresion.ranuras().iter().map(|r| r.nombre()).collect();
+ correr(&mut e, &[MsgAcordes::Tonica(7), MsgAcordes::Modo(1)]);
+ assert_eq!(e.paleta().nombre(), "G menor");
+ let despues: Vec<String> =
+ e.cancion.progresion.ranuras().iter().map(|r| r.nombre()).collect();
+ assert_eq!(antes, despues, "la cinta es del usuario, no de la tonalidad");
+ }
+
+ #[test]
+ fn editar_la_grilla_no_toca_el_patron_de_fabrica() {
+ let mut e = Estado::default();
+ e.patron = 2; // «Rasgueo pop», de fábrica
+ let original = e.banco.get(2).unwrap().clone();
+ correr(&mut e, &[MsgAcordes::Celda(0)]);
+ assert!(!e.banco.es_fijo(e.patron), "el editor saltó a una copia propia");
+ assert_eq!(e.banco.get(2).unwrap(), &original);
+ assert_ne!(e.banco.get(e.patron).unwrap(), &original);
+ }
+
+ #[test]
+ fn el_ritmo_de_una_ranura_se_cicla_y_vuelve_al_de_la_cancion() {
+ let mut e = Estado::default();
+ assert!(e.cancion.progresion.ranura(0).unwrap().patron.is_none());
+ correr(&mut e, &[MsgAcordes::Seleccionar(0), MsgAcordes::PatronRanura]);
+ assert_eq!(e.cancion.progresion.ranura(0).unwrap().patron, Some(0));
+ // Ciclar hasta el final vuelve a «el de la canción».
+ for _ in 0..e.banco.len() {
+ aplicar(&mut e, MsgAcordes::PatronRanura);
+ }
+ assert!(e.cancion.progresion.ranura(0).unwrap().patron.is_none());
+ }
+
+ #[test]
+ fn transponer_mueve_la_cinta_y_la_tonalidad_juntas() {
+ let mut e = Estado::default();
+ correr(&mut e, &[MsgAcordes::Transponer(2)]);
+ assert_eq!(e.paleta().tonica.name(), "D");
+ assert_eq!(e.cancion.progresion.ranura(0).unwrap().nombre(), "D");
+ }
+
+ #[test]
+ fn una_cadencia_reemplaza_la_cinta_entera() {
+ let mut e = Estado::default();
+ correr(&mut e, &[MsgAcordes::Cadencia(9)]); // blues de 12
+ assert_eq!(e.cancion.progresion.len(), 12);
+ assert_eq!(e.sel, 0);
+ }
+
+ #[test]
+ fn quitar_la_ultima_ranura_no_deja_la_seleccion_colgada() {
+ let mut e = Estado::default();
+ e.sel = 3;
+ correr(&mut e, &[MsgAcordes::Quitar]);
+ assert_eq!(e.sel, 2);
+ for _ in 0..5 {
+ aplicar(&mut e, MsgAcordes::Quitar);
+ }
+ assert!(e.cancion.progresion.is_empty());
+ assert_eq!(e.sel, 0);
+ assert!(blip_de_ranura(&e, 0).is_none());
+ }
+
+ #[test]
+ fn el_patron_vacio_es_propio_y_no_suena_hasta_que_se_edita() {
+ let mut e = Estado::default();
+ correr(&mut e, &[MsgAcordes::PatronVacio]);
+ assert!(!e.banco.es_fijo(e.patron));
+ assert_eq!(e.banco.get(e.patron).unwrap().ataques(), 0);
+ correr(&mut e, &[MsgAcordes::Celda(0)]);
+ assert_eq!(e.banco.get(e.patron).unwrap().ataques(), 1);
+ }
+
+ #[test]
+ fn la_cancion_y_los_ritmos_propios_sobreviven_el_cierre() {
+ let dir = std::env::temp_dir().join(format!("takiy-acordes-test-{}", std::process::id()));
+ let _ = std::fs::remove_dir_all(&dir);
+ let mut e = Estado::default();
+ correr(&mut e, &[MsgAcordes::PatronVacio, MsgAcordes::Celda(0), MsgAcordes::Grado(3)]);
+ let propio = e.patron;
+ e.cancion.ajustes.patron = propio;
+ guardar(&mut e, &dir).unwrap();
+
+ let vuelta = cargar(&dir);
+ assert_eq!(vuelta.cancion, e.cancion);
+ assert_eq!(vuelta.banco.len(), e.banco.len());
+ assert_eq!(vuelta.banco.get(propio).unwrap().ataques(), 1);
+ assert_eq!(vuelta.cancion.progresion.len(), 5);
+ let _ = std::fs::remove_dir_all(&dir);
+ }
+
+ #[test]
+ fn un_archivo_ilegible_no_traba_la_pantalla() {
+ let dir = std::env::temp_dir().join(format!("takiy-acordes-roto-{}", std::process::id()));
+ std::fs::create_dir_all(&dir).unwrap();
+ std::fs::write(ruta(&dir), "{ esto no es json").unwrap();
+ assert_eq!(cargar(&dir).cancion.progresion.len(), 4);
+ let _ = std::fs::remove_dir_all(&dir);
+ }
+
+ #[test]
+ fn la_cualidad_elegida_se_marca_en_una_sola_fila() {
+ let n = CUALIDADES.len();
+ for sel in 0..n {
+ let arriba = sel_en_fila(sel, 0, CORTE_CUALIDADES);
+ let abajo = sel_en_fila(sel, CORTE_CUALIDADES, n);
+ let marcadas = (arriba < CORTE_CUALIDADES) as u8
+ + (abajo < n - CORTE_CUALIDADES) as u8;
+ assert_eq!(marcadas, 1, "cualidad {sel} marcada {marcadas} veces");
+ }
+ assert_eq!(sel_en_fila(0, 0, CORTE_CUALIDADES), 0);
+ assert_eq!(sel_en_fila(CORTE_CUALIDADES, CORTE_CUALIDADES, n), 0);
+ }
+
+ #[test]
+ fn el_score_respeta_los_ajustes_de_la_pantalla() {
+ let mut e = Estado::default();
+ correr(&mut e, &[MsgAcordes::Bajo, MsgAcordes::BpmDelta(-4.0)]);
+ let score = e.score();
+ assert_eq!(score.tempo_bpm, 96.0);
+ let nombres: Vec<&str> = score.tracks().iter().map(|t| t.name.as_str()).collect();
+ assert_eq!(nombres, vec!["Acordes"], "el bajo se apagó");
+ }
+}
diff --git a/02_ruway/takiy/takiy-app-llimphi/src/appmodel.rs b/02_ruway/takiy/takiy-app-llimphi/src/appmodel.rs
index 9dba32e..861a55c 100644
--- a/02_ruway/takiy/takiy-app-llimphi/src/appmodel.rs
+++ b/02_ruway/takiy/takiy-app-llimphi/src/appmodel.rs
@@ -15,11 +15,17 @@ use crate::msg::{AutoHit, DragState};
/// siempre— y `Esc` vuelve al panorama.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Screen {
+ /// El wizard de arranque: las cinco maneras de empezar una canción.
+ /// Es la primera pantalla — ver `crate::inicio`.
+ Inicio,
/// Lista de pistas como carriles (cada uno en midi u onda).
Overview,
/// Editor de una pista (piano roll). El índice es la pista abierta;
/// coincide con `editor.active_track`.
Track,
+ /// Pensar la canción desde acordes y ritmos (`takiy-acordes`): paleta
+ /// de la tonalidad, cinta de acordes y editor de patrón rítmico.
+ Acordes,
}
/// Estado del **modo grabación**: el teclado alfabético toca y graba MIDI
@@ -61,6 +67,9 @@ pub(crate) struct SecDrag {
pub(crate) struct Model {
pub(crate) editor: EditorState,
+ /// Estado de la pantalla Acordes. Vive siempre (es barato) para que
+ /// entrar y salir no pierda la progresión en la que se venía trabajando.
+ pub(crate) acordes: crate::acordes::Estado,
pub(crate) source: String,
pub(crate) theme: Theme,
pub(crate) player: Option<Player>,
@@ -173,4 +182,7 @@ pub(crate) struct Model {
/// Modal **Escuchar** (dos escuchas IA sobre la pista activa) abierto, o
/// `None`. Mientras es `Some`, captura el teclado como los otros modales.
pub(crate) escucha: Option<crate::escucha::EscuchaForm>,
+ /// Estado del wizard de arranque (`Screen::Inicio`). Vive siempre: es
+ /// una pantalla, no un modal, y sus perillas se conservan al ir y venir.
+ pub(crate) inicio: crate::inicio::InicioForm,
}
diff --git a/02_ruway/takiy/takiy-app-llimphi/src/chrome.rs b/02_ruway/takiy/takiy-app-llimphi/src/chrome.rs
index f27f59b..1eb1e17 100644
--- a/02_ruway/takiy/takiy-app-llimphi/src/chrome.rs
+++ b/02_ruway/takiy/takiy-app-llimphi/src/chrome.rs
@@ -38,6 +38,7 @@ use crate::appmodel::Model;
// re-exporta para que el resto del binario lo use como `chrome::DockItem`.
pub(crate) use crate::msg::DockItem;
use crate::msg::{InstrumentKind, Msg};
+use rimay_localize::t;
/// Alto de la barra de herramientas (px).
pub(crate) const TOOLBAR_H: f32 = 38.0;
@@ -162,21 +163,27 @@ pub(crate) fn toolbar_bar(model: &Model, theme: &Theme) -> View<Msg> {
let file = ToolbarGroup::new(vec![
tb(Icon::Save, Msg::Save),
- tb(Icon::FolderOpen, Msg::WavImportOpen).with_label("abrir wav"),
+ tb(Icon::FolderOpen, Msg::WavImportOpen).with_label(t("takiy-open-wav")),
tb(Icon::FileText, Msg::ExportMidi).with_label("mid"),
tb(Icon::Music, Msg::ExportWav).with_label("wav"),
]);
- // Génesis (componer con IA) + Escuchar (dos escuchas IA) + Voz (grabar
- // micrófono) — siempre visibles.
+ // Inicio (el wizard) + Génesis (componer con IA) + Escuchar (las
+ // escuchas) + Voz (grabar micrófono) — siempre visibles.
let ia = ToolbarGroup::new(vec![
- tb(Icon::Sparkle, Msg::GenesisOpen).with_label("génesis"),
+ tb(Icon::Home, Msg::InicioOpen)
+ .with_label("inicio")
+ .active(model.screen == crate::appmodel::Screen::Inicio),
+ tb(Icon::Sparkle, Msg::GenesisOpen).with_label(t("takiy-genesis")),
tb(Icon::Equalizer, Msg::EscuchaOpen)
.with_label("escuchar")
.active(model.escucha.is_some()),
tb(Icon::Mic, Msg::VozToggle)
.with_label("voz")
.active(model.voz.is_some()),
+ tb(Icon::Table, Msg::AcordesToggle)
+ .with_label("acordes")
+ .active(model.screen == crate::appmodel::Screen::Acordes),
]);
let mut groups = vec![ia];
@@ -254,8 +261,8 @@ pub(crate) fn right_sidebar(model: &Model, theme: &Theme) -> View<Msg> {
Arc::new(move |size, color| {
View::new(Style {
size: Size { width: length(size), height: length(size) },
- align_items: Some(AlignItems::Center),
- justify_content: Some(JustifyContent::Center),
+ align_items: Some(AlignItems::CENTER),
+ justify_content: Some(JustifyContent::CENTER),
..Default::default()
})
.children(vec![icon_view(ic, color, 2.0)])
@@ -394,7 +401,7 @@ fn section_label(text: &str, theme: &Theme) -> View<Msg> {
height: length(24.0_f32),
},
flex_shrink: 0.0,
- align_items: Some(AlignItems::Center),
+ align_items: Some(AlignItems::CENTER),
padding: Rect {
left: length(0.0_f32),
right: length(0.0_f32),
@@ -418,7 +425,7 @@ fn row_h(h: f32) -> View<Msg> {
height: length(h),
},
flex_shrink: 0.0,
- align_items: Some(AlignItems::Center),
+ align_items: Some(AlignItems::CENTER),
..Default::default()
})
}
@@ -432,7 +439,7 @@ fn hrow(h: f32, gap: f32, kids: Vec<View<Msg>>) -> View<Msg> {
height: length(h),
},
flex_shrink: 0.0,
- align_items: Some(AlignItems::Center),
+ align_items: Some(AlignItems::CENTER),
gap: Size {
width: length(gap),
height: length(0.0_f32),
@@ -470,7 +477,7 @@ fn switch_row(label: &str, on: bool, msg: Msg, theme: &Theme) -> View<Msg> {
width: percent(0.0_f32),
height: percent(1.0_f32),
},
- align_items: Some(AlignItems::Center),
+ align_items: Some(AlignItems::CENTER),
..Default::default()
})
.text_aligned(label.to_string(), 12.0, theme.fg_text, Alignment::Start);
@@ -567,7 +574,7 @@ fn mixer_rows(model: &Model, theme: &Theme, filtro: &str) -> Vec<View<Msg>> {
));
rows.push(line(String::new(), theme)); // separador visual
}
- rows.push(mini_full_button("+ pista nueva", theme, Msg::Edit(EditMsg::NewTrack)));
+ rows.push(mini_full_button(&t("takiy-new-track"), theme, Msg::Edit(EditMsg::NewTrack)));
rows
}
@@ -597,8 +604,8 @@ fn instrumento_rows(model: &Model, theme: &Theme) -> Vec<View<Msg>> {
));
}
None => {
- rows.push(line("sin SF2 cargado — síntesis por osciladores".into(), theme));
- rows.push(line("(TAKIY_SF2=<soundfont> para timbres GM)".into(), theme));
+ rows.push(line(t("takiy-no-sf2"), theme));
+ rows.push(line(t("takiy-sf2-hint"), theme));
}
}
@@ -625,7 +632,7 @@ fn instrumento_rows(model: &Model, theme: &Theme) -> Vec<View<Msg>> {
6.0,
vec![
mini_button("lead", 56.0, is("Lead"), theme, Msg::SetInstrument(InstrumentKind::Lead)),
- mini_button("pad", 56.0, is("Pad cálido"), theme, Msg::SetInstrument(InstrumentKind::Pad)),
+ mini_button("pad", 56.0, is(&t("takiy-warm-pad")), theme, Msg::SetInstrument(InstrumentKind::Pad)),
mini_button("bajo", 56.0, is("Bajo"), theme, Msg::SetInstrument(InstrumentKind::Bass)),
],
));
@@ -639,7 +646,7 @@ fn instrumento_rows(model: &Model, theme: &Theme) -> Vec<View<Msg>> {
],
));
// Describir el sonido en prosa (con IA real opcional) → patch a medida.
- rows.push(mini_full_button("✨ describir sonido…", theme, Msg::InstrOpen));
+ rows.push(mini_full_button(&t("takiy-describe-sound"), theme, Msg::InstrOpen));
rows
}
@@ -700,18 +707,18 @@ fn tonalidad_rows(model: &Model, theme: &Theme) -> Vec<View<Msg>> {
30.0,
6.0,
vec![
- mini_full_button("Raíz", theme, Msg::Edit(EditMsg::CycleKeyRoot)),
+ mini_full_button(&t("takiy-root"), theme, Msg::Edit(EditMsg::CycleKeyRoot)),
mini_full_button("Modo", theme, Msg::Edit(EditMsg::CycleKeyMode)),
],
));
rows.push(switch_row(
- "Snap a la tonalidad",
+ &t("takiy-snap-key"),
model.editor.snap_to_key,
Msg::Edit(EditMsg::ToggleSnapToKey),
theme,
));
- rows.push(section_label("Snap de edición", theme));
+ rows.push(section_label(&t("takiy-snap-edit"), theme));
let idx = SNAPS
.iter()
.position(|s| *s == model.editor.snap)
@@ -767,7 +774,7 @@ fn tonalidad_rows(model: &Model, theme: &Theme) -> Vec<View<Msg>> {
mini_full_button("afinar duro", theme, Msg::VozAfinar { hard: true }),
],
));
- rows.push(mini_full_button("al original", theme, Msg::VozReset));
+ rows.push(mini_full_button(&t("takiy-to-original"), theme, Msg::VozReset));
}
rows
}
@@ -787,16 +794,16 @@ fn automacion_rows(model: &Model, theme: &Theme) -> Vec<View<Msg>> {
.unwrap_or(0);
rows.push(line(format!("pista activa #{active}"), theme));
rows.push(line(format!("vol: {vol_pts} pt · pan: {pan_pts} pt"), theme));
- rows.push(section_label("Anclar punto (al playhead/selección)", theme));
+ rows.push(section_label(&t("takiy-anchor-point"), theme));
rows.push(mini_full_button("Anclar volumen", theme, Msg::AnchorVolumeAutomation));
rows.push(mini_full_button("Anclar pan", theme, Msg::AnchorPanAutomation));
rows.push(section_label("Limpiar", theme));
rows.push(mini_full_button(
- "Borrar automación",
+ &t("takiy-clear-automation"),
theme,
Msg::Edit(EditMsg::ClearActiveAutomation),
));
- rows.push(line("(arrastra los dots en el grid para editar)".into(), theme));
+ rows.push(line(t("takiy-drag-dots"), theme));
rows
}
diff --git a/02_ruway/takiy/takiy-app-llimphi/src/clipstore.rs b/02_ruway/takiy/takiy-app-llimphi/src/clipstore.rs
index 6a60215..387b489 100644
--- a/02_ruway/takiy/takiy-app-llimphi/src/clipstore.rs
+++ b/02_ruway/takiy/takiy-app-llimphi/src/clipstore.rs
@@ -15,6 +15,7 @@ use std::io;
use std::path::{Path, PathBuf};
use takiy_core::Score;
+use rimay_localize::t;
/// Directorio de sidecars para un `.takiy.json`: `<carpeta>/takiy-audio/`.
pub fn audio_dir(score_path: &Path) -> PathBuf {
@@ -126,7 +127,7 @@ fn read_f32_wav(path: &Path) -> io::Result<(Vec<f32>, u32)> {
let b = fs::read(path)?;
let bad = |m: &str| io::Error::new(io::ErrorKind::InvalidData, m.to_string());
if b.len() < 12 || &b[0..4] != b"RIFF" || &b[8..12] != b"WAVE" {
- return Err(bad("no es un WAV"));
+ return Err(bad(&t("takiy-not-wav")));
}
let u16le = |o: usize| u16::from_le_bytes([b[o], b[o + 1]]);
let u32le = |o: usize| u32::from_le_bytes([b[o], b[o + 1], b[o + 2], b[o + 3]]);
@@ -153,7 +154,7 @@ fn read_f32_wav(path: &Path) -> io::Result<(Vec<f32>, u32)> {
i = body + size + (size & 1); // chunks alineados a par
}
- let (start, len) = data.ok_or_else(|| bad("sin chunk data"))?;
+ let (start, len) = data.ok_or_else(|| bad(&t("takiy-no-data-chunk")))?;
let seg = &b[start..start + len];
let ch = channels as usize;
let mono: Vec<f32> = match (fmt, bits) {
@@ -179,7 +180,7 @@ fn read_f32_wav(path: &Path) -> io::Result<(Vec<f32>, u32)> {
acc / ch as f32
})
.collect(),
- _ => return Err(bad("formato WAV no soportado (esperaba f32 o PCM16)")),
+ _ => return Err(bad(&t("takiy-wav-unsupported"))),
};
Ok((mono, sr.max(1)))
}
diff --git a/02_ruway/takiy/takiy-app-llimphi/src/demo.rs b/02_ruway/takiy/takiy-app-llimphi/src/demo.rs
index 6c0b87d..719b598 100644
--- a/02_ruway/takiy/takiy-app-llimphi/src/demo.rs
+++ b/02_ruway/takiy/takiy-app-llimphi/src/demo.rs
@@ -1,6 +1,7 @@
//! Score built-in y bootstrap de carga desde `TAKIY_SCORE_JSON`.
use takiy_core::{Pitch, PitchClass, Score, ScoreNote, Track};
+use rimay_localize::t;
/// Score demo built-in: una escala C mayor + dos notas de bajo. Pensado
/// para que `cargo run -p takiy-app-llimphi` muestre algo audible sin
@@ -9,7 +10,7 @@ use takiy_core::{Pitch, PitchClass, Score, ScoreNote, Track};
pub fn demo_score() -> Score {
let mut score = Score::new(120.0);
- let mut melody = Track::new("melodía");
+ let mut melody = Track::new(t("takiy-melody"));
let degrees = [
PitchClass::C, PitchClass::D, PitchClass::E, PitchClass::F,
PitchClass::G, PitchClass::A, PitchClass::B, PitchClass::C,
diff --git a/02_ruway/takiy/takiy-app-llimphi/src/escucha.rs b/02_ruway/takiy/takiy-app-llimphi/src/escucha.rs
index 9de0df2..0ad266c 100644
--- a/02_ruway/takiy/takiy-app-llimphi/src/escucha.rs
+++ b/02_ruway/takiy/takiy-app-llimphi/src/escucha.rs
@@ -14,6 +14,7 @@ use crate::genesis::{
chip, dialog_row, label_cell, labeled, modal, pill_button, row_h, stepper, text_grow,
};
use crate::msg::Msg;
+use rimay_localize::t;
/// Cuál de las dos escuchas está seleccionada en el modal.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -83,7 +84,7 @@ pub(crate) fn overlay(
theme,
"Escucha",
segmented_view(
- &["Seguir el hilo", "Descifrar"],
+ &[&t("takiy-follow-thread"), "Descifrar"],
form.modo.idx(),
Msg::EscuchaSetMode,
&seg,
@@ -101,13 +102,13 @@ pub(crate) fn overlay(
rows.push(dialog_row(vec![
label_cell(theme, "Semilla"),
text_grow(&form.seed.to_string(), 12.0, theme.fg_muted),
- pill_button(theme, "Otra variante", Msg::EscuchaReroll, false),
+ pill_button(theme, &t("takiy-another-variant"), Msg::EscuchaReroll, false),
]));
- rows.push(hint(theme, "Agrega pistas «Melodía (IA)» y «Acordes (IA)» tras el fragmento."));
+ rows.push(hint(theme, &t("takiy-add-ia-tracks")));
}
EscuchaModo::Deducir => {
- rows.push(hint(theme, "Deduce acordes + loop de ritmo de la pieza y agrega"));
- rows.push(hint(theme, "pistas «Acordes» (pads) y «Batería» con el loop."));
+ rows.push(hint(theme, &t("takiy-deduce-chords")));
+ rows.push(hint(theme, &t("takiy-deduce-chords-2")));
}
}
@@ -153,8 +154,8 @@ fn buttons(theme: &Theme) -> View<Msg> {
flex_direction: FlexDirection::Row,
size: Size { width: percent(1.0_f32), height: length(30.0_f32) },
flex_shrink: 0.0,
- align_items: Some(AlignItems::Center),
- justify_content: Some(JustifyContent::End),
+ align_items: Some(AlignItems::CENTER),
+ justify_content: Some(JustifyContent::END),
gap: Size { width: length(8.0_f32), height: length(0.0_f32) },
margin: Rect {
left: length(0.0_f32),
diff --git a/02_ruway/takiy/takiy-app-llimphi/src/genesis.rs b/02_ruway/takiy/takiy-app-llimphi/src/genesis.rs
index c93ee88..0439d65 100644
--- a/02_ruway/takiy/takiy-app-llimphi/src/genesis.rs
+++ b/02_ruway/takiy/takiy-app-llimphi/src/genesis.rs
@@ -24,10 +24,11 @@ use takiy_core::PitchClass;
use takiy_genesis::{Brief, Mood, Parts, ScaleKind, Style as GStyle};
use crate::msg::Msg;
+use rimay_localize::t;
/// Catálogo de estilos (enum, etiqueta) — el índice es el que viaja en
/// `Msg::GenesisSetStyle`.
-const STYLES: &[(GStyle, &str)] = &[
+pub(crate) const STYLES: &[(GStyle, &str)] = &[
(GStyle::Pop, "Pop"),
(GStyle::Rock, "Rock"),
(GStyle::Folk, "Folk"),
@@ -37,7 +38,7 @@ const STYLES: &[(GStyle, &str)] = &[
];
/// Catálogo de ánimos (enum, etiqueta).
-const MOODS: &[(Mood, &str)] = &[
+pub(crate) const MOODS: &[(Mood, &str)] = &[
(Mood::Brillante, "Brillante"),
(Mood::Oscuro, "Oscuro"),
(Mood::Calmo, "Calmo"),
@@ -149,7 +150,7 @@ pub(crate) fn overlay(
// Título.
rows.push(
row_h(26.0).children(vec![text_grow(
- "Génesis — componer con IA",
+ &t("takiy-genesis-title"),
14.0,
theme.fg_text,
)]),
@@ -162,7 +163,7 @@ pub(crate) fn overlay(
"Idea",
text_input_view_full(
&form.prose,
- "describe la pieza: «balada triste en la menor», «techno oscuro para bailar»…",
+ &t("takiy-genesis-ph"),
true,
&TextInputPalette::from_theme(theme),
Msg::GenesisProseInput,
@@ -209,7 +210,7 @@ pub(crate) fn overlay(
let mood_labels: Vec<&str> = MOODS.iter().map(|(_, l)| *l).collect();
rows.push(labeled(
theme,
- "Ánimo",
+ &t("takiy-mood"),
segmented_view(&mood_labels, form.mood_idx(), Msg::GenesisSetMood, &seg),
));
@@ -217,7 +218,7 @@ pub(crate) fn overlay(
let root_labels: Vec<&str> = (0..12).map(|i| PitchClass::from_semitone(i).name()).collect();
rows.push(labeled(
theme,
- "Raíz",
+ &t("takiy-root"),
segmented_view(
&root_labels,
form.root.semitone() as usize,
@@ -254,7 +255,7 @@ pub(crate) fn overlay(
rows.push(dialog_row(vec![
label_cell(theme, "Semilla"),
text_grow(&form.seed.to_string(), 12.0, theme.fg_muted),
- pill_button(theme, "Otra variante", Msg::GenesisReroll, false),
+ pill_button(theme, &t("takiy-another-variant"), Msg::GenesisReroll, false),
]));
// Interpretación por IA real (LLM) vs mapeo por palabras clave.
@@ -313,8 +314,8 @@ pub(crate) fn modal(rows: Vec<View<Msg>>, theme: &Theme, absorb: Msg) -> View<Ms
View::new(Style {
size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
- align_items: Some(AlignItems::Center),
- justify_content: Some(JustifyContent::Center),
+ align_items: Some(AlignItems::CENTER),
+ justify_content: Some(JustifyContent::CENTER),
..Default::default()
})
.fill(scrim(theme))
@@ -340,7 +341,7 @@ pub(crate) fn row_h(h: f32) -> View<Msg> {
View::new(Style {
size: Size { width: percent(1.0_f32), height: length(h) },
flex_shrink: 0.0,
- align_items: Some(AlignItems::Center),
+ align_items: Some(AlignItems::CENTER),
..Default::default()
})
}
@@ -368,7 +369,7 @@ pub(crate) fn dialog_row(children: Vec<View<Msg>>) -> View<Msg> {
flex_direction: FlexDirection::Row,
size: Size { width: percent(1.0_f32), height: length(30.0_f32) },
flex_shrink: 0.0,
- align_items: Some(AlignItems::Center),
+ align_items: Some(AlignItems::CENTER),
gap: Size { width: length(6.0_f32), height: length(0.0_f32) },
..Default::default()
})
@@ -380,7 +381,7 @@ pub(crate) fn labeled(theme: &Theme, label: &str, control: View<Msg>) -> View<Ms
let slot = View::new(Style {
flex_grow: 1.0,
size: Size { width: percent(0.0_f32), height: length(30.0_f32) },
- align_items: Some(AlignItems::Center),
+ align_items: Some(AlignItems::CENTER),
..Default::default()
})
.children(vec![control]);
@@ -393,7 +394,7 @@ pub(crate) fn stepper(theme: &Theme, value: &str, minus: Msg, plus: Msg) -> View
flex_direction: FlexDirection::Row,
size: Size { width: length(96.0_f32), height: length(26.0_f32) },
flex_shrink: 0.0,
- align_items: Some(AlignItems::Center),
+ align_items: Some(AlignItems::CENTER),
gap: Size { width: length(2.0_f32), height: length(0.0_f32) },
..Default::default()
})
@@ -413,8 +414,8 @@ fn mini(theme: &Theme, glyph: &str, msg: Msg) -> View<Msg> {
View::new(Style {
size: Size { width: length(24.0_f32), height: length(24.0_f32) },
flex_shrink: 0.0,
- align_items: Some(AlignItems::Center),
- justify_content: Some(JustifyContent::Center),
+ align_items: Some(AlignItems::CENTER),
+ justify_content: Some(JustifyContent::CENTER),
..Default::default()
})
.fill(theme.bg_panel)
@@ -434,8 +435,8 @@ pub(crate) fn chip(theme: &Theme, label: &str, active: bool, msg: Msg) -> View<M
View::new(Style {
flex_grow: 1.0,
size: Size { width: percent(0.0_f32), height: length(26.0_f32) },
- align_items: Some(AlignItems::Center),
- justify_content: Some(JustifyContent::Center),
+ align_items: Some(AlignItems::CENTER),
+ justify_content: Some(JustifyContent::CENTER),
..Default::default()
})
.fill(bg)
@@ -455,8 +456,8 @@ pub(crate) fn pill_button(theme: &Theme, label: &str, msg: Msg, accent: bool) ->
View::new(Style {
size: Size { width: Dimension::auto(), height: length(28.0_f32) },
flex_shrink: 0.0,
- align_items: Some(AlignItems::Center),
- justify_content: Some(JustifyContent::Center),
+ align_items: Some(AlignItems::CENTER),
+ justify_content: Some(JustifyContent::CENTER),
padding: Rect {
left: length(12.0_f32),
right: length(12.0_f32),
@@ -481,8 +482,8 @@ fn buttons(theme: &Theme) -> View<Msg> {
flex_direction: FlexDirection::Row,
size: Size { width: percent(1.0_f32), height: length(30.0_f32) },
flex_shrink: 0.0,
- align_items: Some(AlignItems::Center),
- justify_content: Some(JustifyContent::End),
+ align_items: Some(AlignItems::CENTER),
+ justify_content: Some(JustifyContent::END),
gap: Size { width: length(8.0_f32), height: length(0.0_f32) },
margin: Rect {
left: length(0.0_f32),
diff --git a/02_ruway/takiy/takiy-app-llimphi/src/inicio.rs b/02_ruway/takiy/takiy-app-llimphi/src/inicio.rs
new file mode 100644
index 0000000..a3fb875
--- /dev/null
+++ b/02_ruway/takiy/takiy-app-llimphi/src/inicio.rs
@@ -0,0 +1,538 @@
+//! Pantalla **Inicio** — la puerta de takiy: «¿cómo empezamos?».
+//!
+//! takiy tenía los motores para las cinco maneras de empezar una canción
+//! —tararearla, armarla con acordes, describirla, traer un audio, o escribir
+//! nota por nota— y ninguna puerta que las mostrara: la app abría en el
+//! panorama de pistas de un proyecto demo, y el que llegaba por primera vez
+//! no tenía forma de enterarse de que la escucha o el génesis existían. Esto
+//! es esa puerta, y no agrega música: **enhebra** lo que ya estaba.
+//!
+//! Cada camino termina en el mismo lugar (una pieza abierta en el panorama),
+//! así que el wizard no es un modo aparte del que haya que salir: es la
+//! primera pantalla, y se vuelve a ella cuando se quiere empezar otra cosa.
+
+use llimphi_theme::Theme;
+use llimphi_ui::llimphi_layout::taffy::{
+ prelude::{length, percent, Dimension, FlexDirection, Size, Style},
+ AlignItems, JustifyContent, Rect,
+};
+use llimphi_ui::llimphi_text::Alignment;
+use llimphi_ui::View;
+use llimphi_widget_panel::{panel_signature_painter, PanelStyle};
+
+use takiy_acordes::progresion::CADENCIAS;
+
+use crate::genesis::{chip, pill_button, stepper};
+use crate::msg::Msg;
+
+/// En qué parte del wizard está el usuario.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub(crate) enum Paso {
+ /// La lista de caminos.
+ Menu,
+ /// Camino del tarareo: grabar y armar la canción con lo grabado.
+ Tarareo,
+ /// Camino de los acordes: elegir tonalidad y cadencia antes de entrar.
+ Acordes,
+}
+
+/// Estado del wizard. Son las perillas de los dos caminos que tienen
+/// preparación (tarareo y acordes); los otros tres despachan directo.
+pub(crate) struct InicioForm {
+ pub(crate) paso: Paso,
+ /// Índice en [`crate::genesis::STYLES`] — la forma de la canción.
+ pub(crate) estilo: usize,
+ /// Índice en [`crate::genesis::MOODS`].
+ pub(crate) animo: usize,
+ /// Compases por sección del armado.
+ pub(crate) compases: usize,
+ /// Semilla de lo que el motor completa (bajo, relleno, variación).
+ pub(crate) seed: u64,
+ /// Usar el pulso medido al tarareo en vez del groove del estilo.
+ pub(crate) ritmo_del_tarareo: bool,
+ /// Índice en [`takiy_acordes::paleta::RAICES`].
+ pub(crate) tonica: usize,
+ /// Modo menor (si no, mayor) para el camino de acordes.
+ pub(crate) menor: bool,
+ /// Índice en [`CADENCIAS`].
+ pub(crate) cadencia: usize,
+ /// Lo que la escucha entendió del último tarareo — se muestra para que
+ /// el que cantó pueda corregirlo sin volver a cantar.
+ pub(crate) entendido: Option<String>,
+}
+
+impl Default for InicioForm {
+ fn default() -> Self {
+ Self {
+ paso: Paso::Menu,
+ estilo: 0,
+ animo: 0,
+ compases: 8,
+ seed: 1,
+ ritmo_del_tarareo: true,
+ tonica: 0,
+ menor: false,
+ cadencia: 0,
+ entendido: None,
+ }
+ }
+}
+
+/// Los cinco caminos, en el orden en que se ofrecen. El índice es el que
+/// viaja en [`Msg::InicioCamino`].
+pub(crate) const CAMINOS: &[(&str, &str, &str)] = &[
+ (
+ "Tarareá una idea",
+ "Cantá lo que tenés en la cabeza. takiy le saca la tonalidad, el pulso, los acordes y la forma, y te devuelve la canción entera.",
+ "🎤",
+ ),
+ (
+ "Empezá por los acordes",
+ "Una vuelta conocida (o la tuya), girando en loop con un rasgueo. Después le ponés melodía.",
+ "🎹",
+ ),
+ (
+ "Contá qué querés",
+ "«una balada melancólica en la menor». De la frase sale la pieza, y de ahí se edita.",
+ "✨",
+ ),
+ (
+ "Traé un audio",
+ "Un WAV que ya tenés: se le deducen los acordes y el ritmo, y queda dentro del proyecto.",
+ "📂",
+ ),
+ (
+ "Lienzo en blanco",
+ "El piano roll vacío. Nota por nota, como siempre.",
+ "⬜",
+ ),
+];
+
+/// Una tarjeta de camino: título, bajada y un glifo. Toda la tarjeta es el
+/// botón — un blanco grande es el punto de una pantalla de arranque.
+fn camino_card(theme: &Theme, i: usize, destacado: bool) -> View<Msg> {
+ let (titulo, bajada, glifo) = CAMINOS[i];
+ let glifo_col = View::new(Style {
+ size: Size { width: length(44.0_f32), height: percent(1.0_f32) },
+ flex_shrink: 0.0,
+ align_items: Some(AlignItems::CENTER),
+ justify_content: Some(JustifyContent::CENTER),
+ ..Default::default()
+ })
+ .children(vec![View::new(Style {
+ size: Size { width: Dimension::auto(), height: Dimension::auto() },
+ ..Default::default()
+ })
+ .text_aligned(glifo.to_string(), 22.0, theme.fg_text, Alignment::Center)]);
+
+ let texto = View::new(Style {
+ flex_direction: FlexDirection::Column,
+ flex_grow: 1.0,
+ size: Size { width: percent(0.0_f32), height: Dimension::auto() },
+ justify_content: Some(JustifyContent::CENTER),
+ gap: Size { width: length(0.0_f32), height: length(3.0_f32) },
+ ..Default::default()
+ })
+ .children(vec![
+ View::new(Style {
+ size: Size { width: percent(1.0_f32), height: Dimension::auto() },
+ ..Default::default()
+ })
+ .text_aligned(titulo.to_string(), if destacado { 16.0 } else { 14.0 }, theme.fg_text, Alignment::Start),
+ View::new(Style {
+ size: Size { width: percent(1.0_f32), height: Dimension::auto() },
+ ..Default::default()
+ })
+ .text_aligned(bajada.to_string(), 11.5, theme.fg_muted, Alignment::Start),
+ ]);
+
+ View::new(Style {
+ flex_direction: FlexDirection::Row,
+ size: Size { width: percent(1.0_f32), height: length(if destacado { 82.0 } else { 66.0 }) },
+ flex_shrink: 0.0,
+ align_items: Some(AlignItems::CENTER),
+ padding: Rect {
+ left: length(10.0_f32),
+ right: length(14.0_f32),
+ top: length(8.0_f32),
+ bottom: length(8.0_f32),
+ },
+ gap: Size { width: length(6.0_f32), height: length(0.0_f32) },
+ ..Default::default()
+ })
+ .fill(if destacado { theme.bg_row_hover } else { theme.bg_panel })
+ .radius(8.0)
+ .hover_fill(theme.bg_selected)
+ .on_click(Msg::InicioCamino(i))
+ .children(vec![glifo_col, texto])
+}
+
+/// Título + bajada de una pantalla del wizard.
+fn encabezado(theme: &Theme, titulo: &str, bajada: &str) -> View<Msg> {
+ View::new(Style {
+ flex_direction: FlexDirection::Column,
+ size: Size { width: percent(1.0_f32), height: Dimension::auto() },
+ flex_shrink: 0.0,
+ gap: Size { width: length(0.0_f32), height: length(4.0_f32) },
+ margin: Rect {
+ left: length(0.0_f32),
+ right: length(0.0_f32),
+ top: length(0.0_f32),
+ bottom: length(6.0_f32),
+ },
+ ..Default::default()
+ })
+ .children(vec![
+ View::new(Style {
+ size: Size { width: percent(1.0_f32), height: Dimension::auto() },
+ ..Default::default()
+ })
+ .text_aligned(titulo.to_string(), 20.0, theme.fg_text, Alignment::Start),
+ View::new(Style {
+ size: Size { width: percent(1.0_f32), height: Dimension::auto() },
+ ..Default::default()
+ })
+ .text_aligned(bajada.to_string(), 12.0, theme.fg_muted, Alignment::Start),
+ ])
+}
+
+/// Fila de controles con etiqueta a la izquierda (ancho fijo) y el control
+/// ocupando el resto. Espeja `genesis::labeled` pero para filas altas.
+fn fila(theme: &Theme, label: &str, control: View<Msg>) -> View<Msg> {
+ View::new(Style {
+ flex_direction: FlexDirection::Row,
+ size: Size { width: percent(1.0_f32), height: length(30.0_f32) },
+ flex_shrink: 0.0,
+ align_items: Some(AlignItems::CENTER),
+ gap: Size { width: length(8.0_f32), height: length(0.0_f32) },
+ ..Default::default()
+ })
+ .children(vec![
+ View::new(Style {
+ size: Size { width: length(84.0_f32), height: Dimension::auto() },
+ flex_shrink: 0.0,
+ ..Default::default()
+ })
+ .text_aligned(label.to_string(), 11.0, theme.fg_muted, Alignment::Start),
+ control,
+ ])
+}
+
+/// Fila de chips que reparten el ancho.
+fn chips(theme: &Theme, items: Vec<(String, bool, Msg)>) -> View<Msg> {
+ View::new(Style {
+ flex_direction: FlexDirection::Row,
+ flex_grow: 1.0,
+ size: Size { width: percent(0.0_f32), height: length(26.0_f32) },
+ gap: Size { width: length(4.0_f32), height: length(0.0_f32) },
+ ..Default::default()
+ })
+ .children(items.into_iter().map(|(l, a, m)| chip(theme, &l, a, m)).collect())
+}
+
+/// La barra de nivel del micrófono mientras se graba.
+fn vu(theme: &Theme, level: f32) -> View<Msg> {
+ let ancho = (level.clamp(0.0, 1.0) * 100.0).max(1.0);
+ View::new(Style {
+ flex_grow: 1.0,
+ size: Size { width: percent(0.0_f32), height: length(10.0_f32) },
+ ..Default::default()
+ })
+ .fill(theme.bg_panel)
+ .radius(5.0)
+ .children(vec![View::new(Style {
+ size: Size { width: percent(ancho / 100.0), height: percent(1.0_f32) },
+ ..Default::default()
+ })
+ .fill(theme.accent)
+ .radius(5.0)])
+}
+
+/// La pantalla entera. `grabando` trae el nivel y los segundos del micrófono
+/// cuando el camino del tarareo está capturando.
+pub(crate) fn view(
+ form: &InicioForm,
+ theme: &Theme,
+ grabando: Option<(f32, f32)>,
+ recientes: Vec<(usize, String)>,
+) -> View<Msg> {
+ let cuerpo = match form.paso {
+ Paso::Menu => menu(form, theme, recientes),
+ Paso::Tarareo => tarareo(form, theme, grabando),
+ Paso::Acordes => acordes(form, theme),
+ };
+
+ // Columna centrada con ancho máximo: la pantalla de arranque no se
+ // estira a 3000 px en un monitor ancho.
+ let col = View::new(Style {
+ flex_direction: FlexDirection::Column,
+ size: Size { width: percent(1.0_f32), height: Dimension::auto() },
+ max_size: Size { width: length(680.0_f32), height: Dimension::auto() },
+ flex_shrink: 0.0,
+ padding: Rect {
+ left: length(20.0_f32),
+ right: length(20.0_f32),
+ top: length(18.0_f32),
+ bottom: length(18.0_f32),
+ },
+ gap: Size { width: length(0.0_f32), height: length(8.0_f32) },
+ ..Default::default()
+ })
+ .paint_with(panel_signature_painter(PanelStyle::from_theme_large(theme)))
+ .radius(PanelStyle::from_theme_large(theme).radius)
+ .clip(true)
+ .children(cuerpo);
+
+ View::new(Style {
+ size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
+ align_items: Some(AlignItems::CENTER),
+ justify_content: Some(JustifyContent::CENTER),
+ ..Default::default()
+ })
+ .fill(theme.bg_app)
+ .children(vec![col])
+}
+
+fn menu(_form: &InicioForm, theme: &Theme, recientes: Vec<(usize, String)>) -> Vec<View<Msg>> {
+ let mut v = vec![encabezado(
+ theme,
+ "¿Cómo empezamos?",
+ "Cinco maneras de arrancar una canción. Todas terminan en el mismo lugar: la pieza abierta, editable nota por nota.",
+ )];
+ for i in 0..CAMINOS.len() {
+ v.push(camino_card(theme, i, i == 0));
+ }
+ if !recientes.is_empty() {
+ v.push(
+ View::new(Style {
+ size: Size { width: percent(1.0_f32), height: length(22.0_f32) },
+ flex_shrink: 0.0,
+ align_items: Some(AlignItems::CENTER),
+ ..Default::default()
+ })
+ .text_aligned("o seguí con lo último".to_string(), 11.0, theme.fg_muted, Alignment::Start),
+ );
+ v.push(
+ View::new(Style {
+ flex_direction: FlexDirection::Row,
+ size: Size { width: percent(1.0_f32), height: length(28.0_f32) },
+ flex_shrink: 0.0,
+ gap: Size { width: length(6.0_f32), height: length(0.0_f32) },
+ ..Default::default()
+ })
+ .children(
+ recientes
+ .into_iter()
+ .map(|(i, nombre)| pill_button(theme, &nombre, Msg::InicioSeguir(i), false))
+ .collect(),
+ ),
+ );
+ }
+ v
+}
+
+fn tarareo(form: &InicioForm, theme: &Theme, grabando: Option<(f32, f32)>) -> Vec<View<Msg>> {
+ let mut v = vec![encabezado(
+ theme,
+ "Tarareá una idea",
+ "Cantá o silbá unos compases —con «la la la» alcanza—. Cuando pares, takiy transcribe lo que cantaste, le busca la tonalidad y el pulso, lo armoniza y arma la canción alrededor. Tu voz queda como una pista más.",
+ )];
+
+ // Estilo = la forma de la canción (qué secciones y en qué orden).
+ v.push(fila(
+ theme,
+ "estilo",
+ chips(
+ theme,
+ crate::genesis::STYLES
+ .iter()
+ .enumerate()
+ .map(|(i, (_, l))| (l.to_string(), i == form.estilo, Msg::InicioSetEstilo(i)))
+ .collect(),
+ ),
+ ));
+ v.push(fila(
+ theme,
+ "ánimo",
+ chips(
+ theme,
+ crate::genesis::MOODS
+ .iter()
+ .enumerate()
+ .map(|(i, (_, l))| (l.to_string(), i == form.animo, Msg::InicioSetAnimo(i)))
+ .collect(),
+ ),
+ ));
+ v.push(fila(
+ theme,
+ "compases",
+ stepper(theme, &form.compases.to_string(), Msg::InicioCompases(-2), Msg::InicioCompases(2)),
+ ));
+ v.push(fila(
+ theme,
+ "batería",
+ chips(
+ theme,
+ vec![
+ ("tu pulso".to_string(), form.ritmo_del_tarareo, Msg::InicioSetRitmo(true)),
+ ("del estilo".to_string(), !form.ritmo_del_tarareo, Msg::InicioSetRitmo(false)),
+ ],
+ ),
+ ));
+
+ // El botón grande: grabar / parar. Mientras graba, VU + segundos.
+ match grabando {
+ Some((level, secs)) => {
+ v.push(
+ View::new(Style {
+ flex_direction: FlexDirection::Row,
+ size: Size { width: percent(1.0_f32), height: length(34.0_f32) },
+ flex_shrink: 0.0,
+ align_items: Some(AlignItems::CENTER),
+ gap: Size { width: length(8.0_f32), height: length(0.0_f32) },
+ ..Default::default()
+ })
+ .children(vec![
+ View::new(Style {
+ size: Size { width: length(52.0_f32), height: Dimension::auto() },
+ flex_shrink: 0.0,
+ ..Default::default()
+ })
+ .text_aligned(format!("{secs:.1}s"), 13.0, theme.accent, Alignment::Start),
+ vu(theme, level),
+ ]),
+ );
+ v.push(botonera(theme, "Listo — armá la canción", Msg::InicioTarareoParar));
+ }
+ None => {
+ if let Some(entendido) = form.entendido.as_deref() {
+ v.push(
+ View::new(Style {
+ size: Size { width: percent(1.0_f32), height: Dimension::auto() },
+ flex_shrink: 0.0,
+ ..Default::default()
+ })
+ .text_aligned(format!("Lo último que entendí: {entendido}"), 11.0, theme.fg_muted, Alignment::Start),
+ );
+ }
+ v.push(botonera(theme, "● Grabar", Msg::InicioTarareoGrabar));
+ }
+ }
+ v
+}
+
+fn acordes(form: &InicioForm, theme: &Theme) -> Vec<View<Msg>> {
+ use takiy_acordes::paleta::RAICES;
+ let mut v = vec![encabezado(
+ theme,
+ "Empezá por los acordes",
+ "Elegí la tonalidad y una vuelta conocida. Entra girando en loop con un rasgueo, y desde ahí se le cambian los acordes, el ritmo, o se le agrega melodía.",
+ )];
+ v.push(fila(
+ theme,
+ "tónica",
+ chips(
+ theme,
+ RAICES
+ .iter()
+ .enumerate()
+ .map(|(i, r)| (r.name().to_string(), i == form.tonica, Msg::InicioSetTonica(i)))
+ .collect(),
+ ),
+ ));
+ v.push(fila(
+ theme,
+ "modo",
+ chips(
+ theme,
+ vec![
+ ("mayor".to_string(), !form.menor, Msg::InicioSetModo(false)),
+ ("menor".to_string(), form.menor, Msg::InicioSetModo(true)),
+ ],
+ ),
+ ));
+ // Las cadencias, en dos filas para que entren los diez nombres.
+ for tanda in 0..2 {
+ let desde = tanda * 5;
+ let hasta = (desde + 5).min(CADENCIAS.len());
+ if desde >= CADENCIAS.len() {
+ break;
+ }
+ v.push(fila(
+ theme,
+ if tanda == 0 { "vuelta" } else { "" },
+ chips(
+ theme,
+ (desde..hasta)
+ .map(|i| (CADENCIAS[i].0.to_string(), i == form.cadencia, Msg::InicioSetCadencia(i)))
+ .collect(),
+ ),
+ ));
+ }
+ v.push(botonera(theme, "Empezar", Msg::InicioAcordesIr));
+ v
+}
+
+/// Fila de acción: «‹ volver» a la izquierda, el botón principal a la derecha.
+fn botonera(theme: &Theme, label: &str, msg: Msg) -> View<Msg> {
+ View::new(Style {
+ flex_direction: FlexDirection::Row,
+ size: Size { width: percent(1.0_f32), height: length(32.0_f32) },
+ flex_shrink: 0.0,
+ align_items: Some(AlignItems::CENTER),
+ justify_content: Some(JustifyContent::SPACE_BETWEEN),
+ margin: Rect {
+ left: length(0.0_f32),
+ right: length(0.0_f32),
+ top: length(6.0_f32),
+ bottom: length(0.0_f32),
+ },
+ ..Default::default()
+ })
+ .children(vec![
+ pill_button(theme, "‹ volver", Msg::InicioVolver, false),
+ pill_button(theme, label, msg, true),
+ ])
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ /// La vista de los tres pasos se construye sin panic, con y sin
+ /// grabación en curso. Es el smoke barato de una pantalla: un índice
+ /// fuera de rango en los catálogos (estilos, ánimos, cadencias) se
+ /// manifiesta acá y no en metal.
+ #[test]
+ fn los_tres_pasos_arman_su_vista() {
+ let theme = llimphi_theme::Theme::dark();
+ for paso in [Paso::Menu, Paso::Tarareo, Paso::Acordes] {
+ let form = InicioForm { paso, ..InicioForm::default() };
+ let _ = view(&form, &theme, None, vec![(0, "Sin título".into())]);
+ let _ = view(&form, &theme, Some((0.5, 3.2)), Vec::new());
+ }
+ }
+
+ /// El wizard ofrece cinco caminos y el ruteo de `Msg::InicioCamino` los
+ /// cubre a todos (los dos primeros con preparación, los otros tres
+ /// despachan directo). Si alguien agrega una tarjeta sin tocar el
+ /// update, el camino nuevo caería en el `_` de «lienzo en blanco» y
+ /// abriría un proyecto vacío en silencio.
+ #[test]
+ fn hay_cinco_caminos_y_ninguno_esta_vacio() {
+ assert_eq!(CAMINOS.len(), 5);
+ for (titulo, bajada, glifo) in CAMINOS {
+ assert!(!titulo.is_empty() && !bajada.is_empty() && !glifo.is_empty());
+ }
+ }
+
+ /// Los índices que la vista manda viven en catálogos ajenos: si alguno
+ /// se vacía, los chips quedan sin nada que ofrecer.
+ #[test]
+ fn los_catalogos_que_pinta_el_wizard_no_estan_vacios() {
+ assert!(!crate::genesis::STYLES.is_empty());
+ assert!(!crate::genesis::MOODS.is_empty());
+ assert!(!takiy_acordes::paleta::RAICES.is_empty());
+ assert!(!takiy_acordes::progresion::CADENCIAS.is_empty());
+ }
+}
diff --git a/02_ruway/takiy/takiy-app-llimphi/src/instr.rs b/02_ruway/takiy/takiy-app-llimphi/src/instr.rs
index 46212bb..beba3bf 100644
--- a/02_ruway/takiy/takiy-app-llimphi/src/instr.rs
+++ b/02_ruway/takiy/takiy-app-llimphi/src/instr.rs
@@ -11,6 +11,7 @@ use llimphi_widget_text_input::{text_input_view_full, TextInputEvent, TextInputP
use crate::genesis::{chip, dialog_row, label_cell, modal, pill_button, row_h, text_grow};
use crate::msg::Msg;
+use rimay_localize::t;
/// Estado del modal: la caja de prosa, la semilla, el modo IA y la pista
/// destino (la activa cuando se abrió).
@@ -38,7 +39,7 @@ pub(crate) fn overlay(
rows.push(
row_h(26.0).children(vec![text_grow(
- "Instrumento IA — describe el sonido",
+ &t("takiy-ia-instrument"),
14.0,
theme.fg_text,
)]),
@@ -50,7 +51,7 @@ pub(crate) fn overlay(
"Sonido",
text_input_view_full(
&form.prose,
- "describe el timbre: «pad cálido y oscuro», «bajo sub grave», «lead brillante»…",
+ &t("takiy-ia-instrument-ph"),
true,
&TextInputPalette::from_theme(theme),
Msg::InstrProseInput,
@@ -84,7 +85,7 @@ pub(crate) fn overlay(
rows.push(dialog_row(vec![
label_cell(theme, "Semilla"),
text_grow(&form.seed.to_string(), 12.0, theme.fg_muted),
- pill_button(theme, "Otra variante", Msg::InstrReroll, false),
+ pill_button(theme, &t("takiy-another-variant"), Msg::InstrReroll, false),
]));
// Botones.
@@ -102,7 +103,7 @@ fn labeled(theme: &Theme, label: &str, control: View<Msg>) -> View<Msg> {
let slot = View::new(Style {
flex_grow: 1.0,
size: Size { width: percent(0.0_f32), height: length(30.0_f32) },
- align_items: Some(AlignItems::Center),
+ align_items: Some(AlignItems::CENTER),
..Default::default()
})
.children(vec![control]);
@@ -118,8 +119,8 @@ fn buttons(theme: &Theme) -> View<Msg> {
flex_direction: FlexDirection::Row,
size: Size { width: percent(1.0_f32), height: length(30.0_f32) },
flex_shrink: 0.0,
- align_items: Some(AlignItems::Center),
- justify_content: Some(JustifyContent::End),
+ align_items: Some(AlignItems::CENTER),
+ justify_content: Some(JustifyContent::END),
gap: Size { width: length(8.0_f32), height: length(0.0_f32) },
margin: Rect {
left: length(0.0_f32),
diff --git a/02_ruway/takiy/takiy-app-llimphi/src/llmhud.rs b/02_ruway/takiy/takiy-app-llimphi/src/llmhud.rs
index 1eafaca..cba9156 100644
--- a/02_ruway/takiy/takiy-app-llimphi/src/llmhud.rs
+++ b/02_ruway/takiy/takiy-app-llimphi/src/llmhud.rs
@@ -29,7 +29,7 @@ pub(crate) fn overlay(text: &str, theme: &Theme) -> View<Msg> {
let pill = View::new(Style {
size: Size { width: Dimension::auto(), height: length(34.0_f32) },
flex_shrink: 0.0,
- align_items: Some(AlignItems::Center),
+ align_items: Some(AlignItems::CENTER),
padding: Rect {
left: length(16.0_f32),
right: length(16.0_f32),
@@ -49,8 +49,8 @@ pub(crate) fn overlay(text: &str, theme: &Theme) -> View<Msg> {
View::new(Style {
size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
flex_direction: FlexDirection::Column,
- align_items: Some(AlignItems::Center),
- justify_content: Some(JustifyContent::Start),
+ align_items: Some(AlignItems::CENTER),
+ justify_content: Some(JustifyContent::START),
padding: Rect {
left: length(0.0_f32),
right: length(0.0_f32),
diff --git a/02_ruway/takiy/takiy-app-llimphi/src/main.rs b/02_ruway/takiy/takiy-app-llimphi/src/main.rs
index 8d1e190..02ca09f 100644
--- a/02_ruway/takiy/takiy-app-llimphi/src/main.rs
+++ b/02_ruway/takiy/takiy-app-llimphi/src/main.rs
@@ -35,11 +35,13 @@
//! - `Del`/`⌫` — borra la nota seleccionada. `Ctrl+⌫` borra la pista activa.
//! - `S` guarda · `,/.` tempo · `p/P` programa GM · `Esc` cierra.
+mod acordes;
mod appmodel;
mod audio;
mod chrome;
mod escucha;
mod genesis;
+mod inicio;
mod instr;
mod llmcfg;
mod llmhud;
@@ -84,6 +86,7 @@ use crate::audio::load_sf2;
use crate::msg::Msg;
use crate::paint::paint_piano_roll;
use crate::update::{actualizar, build_editor};
+use rimay_localize::t;
struct Takiy;
@@ -143,6 +146,7 @@ impl App for Takiy {
Model {
editor,
+ acordes: crate::acordes::cargar(&proy_dir),
source,
theme: Theme::dark(),
player,
@@ -163,25 +167,35 @@ impl App for Takiy {
// Sidebar izquierdo (selector de proyectos): abre desplegado sobre
// el proyecto 0. Derecho (dock de paneles): arranca colapsado (sólo
// el rail de dientes a la vista).
- sidebar_left: llimphi_widget_rag_sidebar::RagSidebarState {
- open: Some(0),
- selected: Some(0),
- panel_w: crate::chrome::DEFAULT_PANEL_W,
- // Rail contra el borde de ventana (estilo activity bar), como el
- // piano roll de siempre.
- rail_outside: true,
- ..Default::default()
+ sidebar_left: {
+ let mut s = llimphi_widget_rag_sidebar::RagSidebarState {
+ open: Some(0),
+ selected: Some(0),
+ panel_w: crate::chrome::DEFAULT_PANEL_W,
+ // Rail contra el borde de ventana (estilo activity bar), como el
+ // piano roll de siempre.
+ rail_outside: true,
+ ..Default::default()
+ };
+ // El ancho al que quedó el divisor la última vez (si lo hubo).
+ llimphi_widget_rag_sidebar::persistencia::restaurar(&mut s, "takiy", "izq");
+ s
},
- sidebar_right: llimphi_widget_rag_sidebar::RagSidebarState {
- open: None,
- selected: None,
- panel_w: crate::chrome::DEFAULT_PANEL_W,
- rail_outside: true,
- ..Default::default()
+ sidebar_right: {
+ let mut s = llimphi_widget_rag_sidebar::RagSidebarState {
+ open: None,
+ selected: None,
+ panel_w: crate::chrome::DEFAULT_PANEL_W,
+ rail_outside: true,
+ ..Default::default()
+ };
+ llimphi_widget_rag_sidebar::persistencia::restaurar(&mut s, "takiy", "der");
+ s
},
- // El proyecto se abre en el panorama de pistas (tipo Audacity);
- // clickear un carril entra al piano roll de esa pista.
- screen: Screen::Overview,
+ // La app abre en el **wizard** (`crate::inicio`): las cinco
+ // maneras de empezar una canción. Los caminos terminan en el
+ // panorama de pistas, y «seguí con lo último» entra directo.
+ screen: Screen::Inicio,
onda_peaks: std::collections::HashMap::new(),
wave_sel: None,
recording: None,
@@ -202,6 +216,7 @@ impl App for Takiy {
llm_model: None,
wavimport: None,
escucha: None,
+ inicio: crate::inicio::InicioForm::default(),
}
}
@@ -345,6 +360,43 @@ impl App for Takiy {
_ => None,
};
}
+ // Wizard de arranque: el teclado es suyo. Espacio graba/para el
+ // tarareo (el gesto de la pantalla), Esc retrocede un paso y, desde
+ // el menú, entra a lo que ya estaba abierto. Ninguna tecla del
+ // piano roll aplica acá.
+ if model.screen == Screen::Inicio {
+ return match &event.key {
+ Key::Named(NamedKey::Space)
+ if model.inicio.paso == crate::inicio::Paso::Tarareo =>
+ {
+ Some(if model.voz.is_some() {
+ Msg::InicioTarareoParar
+ } else {
+ Msg::InicioTarareoGrabar
+ })
+ }
+ Key::Named(NamedKey::Escape)
+ if model.inicio.paso != crate::inicio::Paso::Menu =>
+ {
+ Some(Msg::InicioVolver)
+ }
+ Key::Named(NamedKey::Escape) => Some(Msg::OpenOverview),
+ _ => None,
+ };
+ }
+
+ // Pantalla Acordes: el teclado es el de esa pantalla — Space toca
+ // la progresión y Esc vuelve al panorama. Las teclas del piano
+ // roll (mover notas, snap, pistas) no aplican acá.
+ if model.screen == Screen::Acordes {
+ return match &event.key {
+ Key::Named(NamedKey::Space) => {
+ Some(Msg::Acordes(crate::acordes::MsgAcordes::Tocar))
+ }
+ Key::Named(NamedKey::Escape) => Some(Msg::OpenOverview),
+ _ => None,
+ };
+ }
match &event.key {
Key::Named(NamedKey::Space) if event.modifiers.ctrl => Some(Msg::PlayWithCountIn),
Key::Named(NamedKey::Space) => Some(Msg::TogglePlay),
@@ -514,6 +566,29 @@ impl App for Takiy {
// editor; en el editor lleva además el botón «‹ pistas»).
let toolbar = chrome::toolbar_bar(model, &theme);
+ // El wizard de arranque: pantalla completa, sin toolbar (no hay
+ // nada que editar todavía) pero con el menú, para que se pueda
+ // abrir un archivo o salir.
+ if matches!(model.screen, Screen::Inicio) {
+ let grabando = model.voz.as_ref().map(|v| (v.level, v.seconds));
+ let recientes: Vec<(usize, String)> = model
+ .proyectos
+ .iter()
+ .enumerate()
+ .rev()
+ .take(4)
+ .map(|(i, p)| (i, p.nombre.clone()))
+ .collect();
+ let body = scene_body(inicio::view(&model.inicio, &theme, grabando, recientes), sk);
+ return View::new(Style {
+ flex_direction: FlexDirection::Column,
+ size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
+ ..Default::default()
+ })
+ .fill(theme.bg_app)
+ .children(vec![menubar, body]);
+ }
+
// Panorama de pistas (tipo Audacity): la pantalla con la que se
// abre el proyecto. El piano roll queda detrás de un click.
if matches!(model.screen, Screen::Overview) {
@@ -527,6 +602,21 @@ impl App for Takiy {
.children(vec![menubar, toolbar, body]);
}
+ // Pantalla Acordes: pensar la canción desde acordes y ritmos.
+ if matches!(model.screen, Screen::Acordes) {
+ let body = scene_body(
+ acordes::body(&model.acordes, &theme, model.playing),
+ sk,
+ );
+ return View::new(Style {
+ flex_direction: FlexDirection::Column,
+ size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
+ ..Default::default()
+ })
+ .fill(theme.bg_app)
+ .children(vec![menubar, toolbar, body]);
+ }
+
// Modo grabación: teclado-piano que graba MIDI (tiene prioridad
// sobre el editor de la pista).
if model.recording.is_some() {
@@ -762,6 +852,10 @@ fn scene_key(model: &Model) -> u64 {
0
} else if matches!(model.screen, Screen::Overview) {
1
+ } else if matches!(model.screen, Screen::Acordes) {
+ 4
+ } else if matches!(model.screen, Screen::Inicio) {
+ 5
} else {
let onda = model
.editor
@@ -904,22 +998,23 @@ fn app_menu() -> AppMenu {
.item(MenuItem::new("Cortar", "edit.cut").shortcut("Ctrl+X"))
.item(MenuItem::new("Pegar", "edit.paste").shortcut("Ctrl+V"))
.item(MenuItem::new("Duplicar", "edit.duplicate").shortcut("Ctrl+D"))
- .item(MenuItem::new("Borrar selección", "edit.delete").shortcut("Del").separated())
- .item(MenuItem::new("Pista nueva", "edit.new_track").shortcut("N"))
+ .item(MenuItem::new(t("takiy-delete-selection"), "edit.delete").shortcut("Del").separated())
+ .item(MenuItem::new(t("takiy-new-track-item"), "edit.new_track").shortcut("N"))
.item(MenuItem::new("Ciclar pista", "edit.cycle_track").shortcut("Tab"))
- .item(MenuItem::new("Borrar pista activa", "edit.delete_track")),
+ .item(MenuItem::new(t("takiy-delete-track"), "edit.delete_track")),
)
.menu(
- Menu::new("Reproducción")
+ Menu::new(t("takiy-playback"))
.item(MenuItem::new("Tocar / Detener", "play.toggle").shortcut("Space"))
- .item(MenuItem::new("Tocar con count-in", "play.countin").shortcut("Ctrl+Space"))
- .item(MenuItem::new("Metrónomo", "play.metronome").shortcut("M").separated())
+ .item(MenuItem::new(t("takiy-play-countin"), "play.countin").shortcut("Ctrl+Space"))
+ .item(MenuItem::new(t("takiy-metronome"), "play.metronome").shortcut("M").separated())
.item(MenuItem::new("Loop", "play.loop").shortcut("L")),
)
.menu(
Menu::new("Ver")
.item(MenuItem::new("Ciclar snap", "view.snap").shortcut("Q"))
- .item(MenuItem::new("Snap a tonalidad", "view.snap_key").shortcut("Alt+K")),
+ .item(MenuItem::new("Snap a tonalidad", "view.snap_key").shortcut("Alt+K"))
+ .item(MenuItem::new(t("takiy-chords-rhythms"), "view.acordes").separated()),
)
.menu(Menu::new("Ayuda").item(MenuItem::new("Acerca de", "help.about")))
}
@@ -928,6 +1023,7 @@ fn app_menu() -> AppMenu {
/// real y lo dispatcha. Todos los ids mapean a acciones que ya existían.
fn handle_menu_command(cmd: &str, handle: &Handle<Msg>) {
let msg = match cmd {
+ "view.acordes" => Some(Msg::AcordesToggle),
"file.save" => Some(Msg::Save),
"file.export_midi" => Some(Msg::ExportMidi),
"file.export_wav" => Some(Msg::ExportWav),
@@ -966,14 +1062,14 @@ fn context_menu_for_selection(model: &Model, x: f32, y: f32) -> View<Msg> {
.selected
.and_then(|(t, i)| model.editor.score.track(t).and_then(|tr| tr.notes().get(i)).copied())
.map(|n| format!("nota midi {}", n.pitch.midi()))
- .unwrap_or_else(|| "selección".to_string());
+ .unwrap_or_else(|| t("takiy-selection"));
let mut items = vec![
ContextMenuItem::action("Copiar").with_shortcut("Ctrl+C"),
ContextMenuItem::action("Cortar").with_shortcut("Ctrl+X"),
ContextMenuItem::action("Duplicar").with_shortcut("Ctrl+D"),
];
- let paste = ContextMenuItem::action("Pegar al playhead").with_shortcut("Ctrl+V");
+ let paste = ContextMenuItem::action(t("takiy-paste-playhead")).with_shortcut("Ctrl+V");
items.push(if has_clipboard { paste } else { paste.disabled() });
items.push(ContextMenuItem::separator());
items.push(ContextMenuItem::action("Borrar").with_shortcut("Del").destructive());
diff --git a/02_ruway/takiy/takiy-app-llimphi/src/msg.rs b/02_ruway/takiy/takiy-app-llimphi/src/msg.rs
index 65847fd..a861030 100644
--- a/02_ruway/takiy/takiy-app-llimphi/src/msg.rs
+++ b/02_ruway/takiy/takiy-app-llimphi/src/msg.rs
@@ -54,6 +54,10 @@ const AUTO_LINE_HIT_RADIUS_PX: f32 = 5.0;
#[derive(Clone)]
pub(crate) enum Msg {
+ /// Todo lo que pasa en la pantalla Acordes (ver `crate::acordes`).
+ Acordes(crate::acordes::MsgAcordes),
+ /// Entra a la pantalla Acordes (y vuelve al panorama si ya estaba).
+ AcordesToggle,
TogglePlay,
/// Toca el score con un count-in de 1 compás (clicks pero sin notas).
/// Útil para grabar a tempo desde el principio.
@@ -246,6 +250,35 @@ pub(crate) enum Msg {
/// Devuelve el clip de la pista activa a su tono original (revierte
/// autotune + pitch-shift).
VozReset,
+ // --- Inicio (el wizard de arranque) ---
+ /// Vuelve a la pantalla de inicio (sin tocar el proyecto abierto).
+ InicioOpen,
+ /// Elige uno de los cinco caminos (índice en `inicio::CAMINOS`).
+ InicioCamino(usize),
+ /// Vuelve al menú de caminos desde un camino con preparación.
+ InicioVolver,
+ /// Sigue con un proyecto ya abierto (índice en `model.proyectos`).
+ InicioSeguir(usize),
+ /// Abre el micrófono para el tarareo.
+ InicioTarareoGrabar,
+ /// Para el micrófono y **arma la canción** con lo grabado.
+ InicioTarareoParar,
+ /// Estilo del armado (índice en `genesis::STYLES`) — decide la forma.
+ InicioSetEstilo(usize),
+ /// Ánimo del armado (índice en `genesis::MOODS`).
+ InicioSetAnimo(usize),
+ /// Compases por sección, ±.
+ InicioCompases(i32),
+ /// `true` = batería con el pulso medido al tarareo; `false` = del estilo.
+ InicioSetRitmo(bool),
+ /// Tónica del camino de acordes (índice en `paleta::RAICES`).
+ InicioSetTonica(usize),
+ /// Modo del camino de acordes (`true` = menor).
+ InicioSetModo(bool),
+ /// Vuelta armónica del camino de acordes (índice en `CADENCIAS`).
+ InicioSetCadencia(usize),
+ /// Entra a la pantalla de acordes con lo elegido.
+ InicioAcordesIr,
// --- Génesis (panel generativo por IA) ---
/// Abre el diálogo modal de Génesis (componer con IA por prosa).
GenesisOpen,
diff --git a/02_ruway/takiy/takiy-app-llimphi/src/overview.rs b/02_ruway/takiy/takiy-app-llimphi/src/overview.rs
index 6129e07..fdab5d7 100644
--- a/02_ruway/takiy/takiy-app-llimphi/src/overview.rs
+++ b/02_ruway/takiy/takiy-app-llimphi/src/overview.rs
@@ -31,6 +31,7 @@ use takiy_app::EditMsg;
use crate::appmodel::Model;
use crate::msg::Msg;
+use rimay_localize::t;
/// Resolución fija del perfil de picos de onda. El painter remapea estos
/// buckets al ancho real del carril, así el cálculo no depende del tamaño
@@ -176,14 +177,14 @@ pub(crate) fn body(model: &Model, theme: &Theme) -> View<Msg> {
flex_grow: 1.0,
size: Size { width: percent(1.0_f32), height: percent(0.0_f32) },
min_size: Size { width: length(0.0_f32), height: length(LANE_MIN_H) },
- align_items: Some(AlignItems::Center),
- justify_content: Some(JustifyContent::Center),
+ align_items: Some(AlignItems::CENTER),
+ justify_content: Some(JustifyContent::CENTER),
..Default::default()
})
.children(vec![empty_view::<Msg>(
Icon::Music,
- "Proyecto sin pistas",
- Some("Agrega la primera con «+ pista nueva» abajo."),
+ t("takiy-no-tracks"),
+ Some(&t("takiy-add-first-track")),
&EmptyPalette::from_theme(theme),
)]);
lanes.push(card);
@@ -227,7 +228,7 @@ fn lane_row(header: View<Msg>, strip: View<Msg>, is_active: bool, theme: &Theme)
top: length(5.0_f32),
bottom: length(5.0_f32),
},
- align_items: Some(AlignItems::Stretch),
+ align_items: Some(AlignItems::STRETCH),
..Default::default()
})
.fill(bg)
@@ -553,7 +554,7 @@ fn add_track_row(theme: &Theme) -> View<Msg> {
..Default::default()
})
.children(vec![button_view(
- "+ pista nueva",
+ t("takiy-new-track"),
&pal,
Msg::Edit(EditMsg::NewTrack),
)]);
@@ -561,7 +562,7 @@ fn add_track_row(theme: &Theme) -> View<Msg> {
flex_direction: FlexDirection::Row,
size: Size { width: percent(1.0_f32), height: length(38.0_f32) },
flex_shrink: 0.0,
- align_items: Some(AlignItems::Center),
+ align_items: Some(AlignItems::CENTER),
padding: Rect {
left: length(6.0_f32),
right: length(6.0_f32),
@@ -606,7 +607,7 @@ fn hrow(h: f32, gap: f32, kids: Vec<View<Msg>>) -> View<Msg> {
flex_direction: FlexDirection::Row,
size: Size { width: percent(1.0_f32), height: length(h) },
flex_shrink: 0.0,
- align_items: Some(AlignItems::Center),
+ align_items: Some(AlignItems::CENTER),
gap: Size { width: length(gap), height: length(0.0_f32) },
..Default::default()
})
diff --git a/02_ruway/takiy/takiy-app-llimphi/src/proyecto.rs b/02_ruway/takiy/takiy-app-llimphi/src/proyecto.rs
index be262b9..87fd7ce 100644
--- a/02_ruway/takiy/takiy-app-llimphi/src/proyecto.rs
+++ b/02_ruway/takiy/takiy-app-llimphi/src/proyecto.rs
@@ -27,6 +27,7 @@ use takiy_proyecto::Proyecto;
use crate::appmodel::Model;
use crate::chrome::RAIL_W;
use crate::msg::Msg;
+use rimay_localize::t;
/// Alto de una fila de commit en el grafo de versiones.
const COMMIT_H: f32 = 30.0;
@@ -61,8 +62,8 @@ pub(crate) fn left_sidebar(model: &Model, theme: &Theme) -> View<Msg> {
Arc::new(move |size, color| {
View::new(Style {
size: Size { width: length(size), height: length(size) },
- align_items: Some(AlignItems::Center),
- justify_content: Some(JustifyContent::Center),
+ align_items: Some(AlignItems::CENTER),
+ justify_content: Some(JustifyContent::CENTER),
..Default::default()
})
.text_aligned(label.clone(), 14.0, color, Alignment::Center)
@@ -89,7 +90,7 @@ pub(crate) fn left_sidebar(model: &Model, theme: &Theme) -> View<Msg> {
on_drop: None,
on_reorder: None,
// Encía «+» al final del rail: abre un proyecto nuevo.
- grow: Some(("+".to_string(), "Abrir proyecto nuevo".to_string(), Msg::ProyectoNuevo)),
+ grow: Some(("+".to_string(), t("takiy-open-new-project"), Msg::ProyectoNuevo)),
};
rag_sidebar_view(&model.sidebar_left, spec, RagSide::Left, &RagSidebarPalette::from_theme(theme))
}
@@ -111,7 +112,7 @@ fn left_body(model: &Model, theme: &Theme, filtro: &str) -> View<Msg> {
// --- Versiones ---
kids.push(section_header("Versiones", model.ver_versiones, Msg::ToggleVersiones, theme));
if model.ver_versiones {
- kids.push(full_button("+ guardar versión", theme, Msg::GuardarVersion));
+ kids.push(full_button(&t("takiy-save-version"), theme, Msg::GuardarVersion));
kids.push(version_graph(proy, &f, theme));
}
@@ -119,7 +120,7 @@ fn left_body(model: &Model, theme: &Theme, filtro: &str) -> View<Msg> {
// proyecto, que sólo se sincroniza al cambiar/guardar).
kids.push(section_header("Pistas", model.ver_pistas, Msg::TogglePistas, theme));
if model.ver_pistas {
- kids.push(legend("ver · mute · nombre = abrir", theme));
+ kids.push(legend(&t("takiy-track-hint"), theme));
for (i, track) in model.editor.score.tracks().iter().enumerate() {
// El buscador filtra por nombre de pista (substring insensible).
if !f.is_empty() && !track.name.to_lowercase().contains(&f) {
@@ -131,7 +132,7 @@ fn left_body(model: &Model, theme: &Theme, filtro: &str) -> View<Msg> {
.animated_enter(0xC30_0000 + i as u64, motion::NORMAL),
);
}
- kids.push(full_button("+ pista nueva", theme, Msg::Edit(EditMsg::NewTrack)));
+ kids.push(full_button(&t("takiy-new-track"), theme, Msg::Edit(EditMsg::NewTrack)));
}
View::new(Style {
@@ -231,9 +232,9 @@ fn version_graph(proy: &Proyecto, filtro: &str, theme: &Theme) -> View<Msg> {
.collect();
if hist.is_empty() {
let txt = if filtro.is_empty() {
- "sin versiones — «guardar versión» sella la primera".to_string()
+ t("takiy-no-versions")
} else {
- "sin versiones que coincidan".to_string()
+ t("takiy-no-matching-versions")
};
return row_h(20.0).text_aligned(txt, 10.0, theme.fg_muted, Alignment::Start);
}
@@ -313,7 +314,7 @@ fn commit_row(
flex_direction: FlexDirection::Row,
size: Size { width: percent(1.0_f32), height: length(COMMIT_H) },
flex_shrink: 0.0,
- align_items: Some(AlignItems::Center),
+ align_items: Some(AlignItems::CENTER),
..Default::default()
})
.children(vec![spine, btn])
@@ -344,7 +345,7 @@ fn track_row(i: usize, track: &takiy_core::Track, is_active: bool, theme: &Theme
flex_direction: FlexDirection::Row,
size: Size { width: percent(1.0_f32), height: length(28.0_f32) },
flex_shrink: 0.0,
- align_items: Some(AlignItems::Center),
+ align_items: Some(AlignItems::CENTER),
gap: Size { width: length(3.0_f32), height: length(0.0_f32) },
..Default::default()
})
@@ -376,7 +377,7 @@ fn row_h(h: f32) -> View<Msg> {
View::new(Style {
size: Size { width: percent(1.0_f32), height: length(h) },
flex_shrink: 0.0,
- align_items: Some(AlignItems::Center),
+ align_items: Some(AlignItems::CENTER),
..Default::default()
})
}
diff --git a/02_ruway/takiy/takiy-app-llimphi/src/record.rs b/02_ruway/takiy/takiy-app-llimphi/src/record.rs
index 4bd72c0..2699907 100644
--- a/02_ruway/takiy/takiy-app-llimphi/src/record.rs
+++ b/02_ruway/takiy/takiy-app-llimphi/src/record.rs
@@ -131,7 +131,7 @@ fn hud_bar(rec: &crate::appmodel::RecState, name: &str, theme: &Theme) -> View<M
let info = View::new(Style {
flex_grow: 1.0,
size: Size { width: percent(0.0_f32), height: percent(1.0_f32) },
- align_items: Some(AlignItems::Center),
+ align_items: Some(AlignItems::CENTER),
..Default::default()
})
.text_aligned(
@@ -159,7 +159,7 @@ fn hud_bar(rec: &crate::appmodel::RecState, name: &str, theme: &Theme) -> View<M
flex_direction: FlexDirection::Row,
size: Size { width: percent(1.0_f32), height: length(44.0_f32) },
flex_shrink: 0.0,
- align_items: Some(AlignItems::Center),
+ align_items: Some(AlignItems::CENTER),
gap: Size { width: length(6.0_f32), height: length(0.0_f32) },
padding: Rect {
left: length(10.0_f32),
diff --git a/02_ruway/takiy/takiy-app-llimphi/src/secedit.rs b/02_ruway/takiy/takiy-app-llimphi/src/secedit.rs
index 4f56729..925de09 100644
--- a/02_ruway/takiy/takiy-app-llimphi/src/secedit.rs
+++ b/02_ruway/takiy/takiy-app-llimphi/src/secedit.rs
@@ -13,6 +13,7 @@ use llimphi_widget_text_input::{text_input_view_full, TextInputEvent, TextInputP
use crate::genesis::{dialog_row, label_cell, modal, pill_button, row_h, text_grow};
use crate::msg::Msg;
+use rimay_localize::t;
/// Estado del modal: la caja del nombre y el índice de la sección editada.
pub(crate) struct SectionEditForm {
@@ -59,8 +60,8 @@ pub(crate) fn overlay(form: &SectionEditForm, theme: &Theme) -> View<Msg> {
flex_direction: FlexDirection::Row,
size: Size { width: percent(1.0_f32), height: length(30.0_f32) },
flex_shrink: 0.0,
- align_items: Some(AlignItems::Center),
- justify_content: Some(JustifyContent::End),
+ align_items: Some(AlignItems::CENTER),
+ justify_content: Some(JustifyContent::END),
gap: Size { width: length(8.0_f32), height: length(0.0_f32) },
margin: Rect {
left: length(0.0_f32),
@@ -73,7 +74,7 @@ pub(crate) fn overlay(form: &SectionEditForm, theme: &Theme) -> View<Msg> {
.children(vec![
pill_button(theme, "Cancelar", Msg::SectionEditCancel, false),
pill_button(theme, "Borrar", Msg::SectionDelete, false),
- pill_button(theme, "+ después", Msg::SectionAddAfter, false),
+ pill_button(theme, &t("takiy-after"), Msg::SectionAddAfter, false),
pill_button(theme, "Guardar", Msg::SectionRename, true),
]),
);
@@ -86,7 +87,7 @@ fn labeled(theme: &Theme, label: &str, control: View<Msg>) -> View<Msg> {
let slot = View::new(Style {
flex_grow: 1.0,
size: Size { width: percent(0.0_f32), height: length(30.0_f32) },
- align_items: Some(AlignItems::Center),
+ align_items: Some(AlignItems::CENTER),
..Default::default()
})
.children(vec![control]);
diff --git a/02_ruway/takiy/takiy-app-llimphi/src/update.rs b/02_ruway/takiy/takiy-app-llimphi/src/update.rs
index 9190292..6e27dca 100644
--- a/02_ruway/takiy/takiy-app-llimphi/src/update.rs
+++ b/02_ruway/takiy/takiy-app-llimphi/src/update.rs
@@ -20,6 +20,7 @@ use crate::audio::{
build_play, play_status_extras, render_score, render_with_wave, WAV_EXPORT_SAMPLE_RATE,
};
use crate::chrome::{RAIL_W, TOOLBAR_H};
+use rimay_localize::t;
use crate::msg::{
hit_test_automation_dot, hit_test_automation_line, DragMode, DragState, Msg,
AUTO_LANE_MARGIN_PX, RESIZE_EDGE_PX,
@@ -89,25 +90,76 @@ fn aplicar_instrumento(model: &mut Model, track: usize, patch: takiy_core::Patch
/// Abre un `score` recién generado (Génesis) como un proyecto nuevo — mismo
/// camino que `ProyectoNuevo`, con toast de confirmación. Compartido por la
/// generación determinista y la del LLM.
-fn abrir_pieza_generada(model: &mut Model, handle: &Handle<Msg>, score: Score) {
+fn abrir_pieza_generada(
+ model: &mut Model,
+ handle: &Handle<Msg>,
+ score: Score,
+ origen: &str,
+) {
let n_notas: usize = score.tracks().iter().map(|t| t.notes().len()).sum();
let n_pistas = score.tracks().len();
sync_active_proyecto(model);
guardar_proyecto_activo(model);
let nn = model.proyectos.len() + 1;
- let proy = Proyecto::nuevo(format!("génesis {nn}"), score.clone());
+ let proy = Proyecto::nuevo(format!("{origen} {nn}"), score.clone());
model.proyectos.push(proy);
model.proy_activo = model.proyectos.len() - 1;
model.editor = build_editor(score);
model.recording = None;
model.screen = Screen::Overview;
model.onda_peaks.clear();
- model.status = format!("génesis · {n_pistas} pistas · {n_notas} notas");
+ model.status = format!("{origen} · {n_pistas} pistas · {n_notas} notas");
push_toast(model, handle, move |id| {
Toast::success(id, format!("Pieza generada · {n_notas} notas"), TOAST_TTL)
});
}
+/// El camino del **tarareo**: de lo que se cantó a la canción entera.
+///
+/// Es la escucha 3 (`takiy_escucha::armar`) más dos cosas que sólo tienen
+/// sentido acá: la voz original queda como una pista de audio más (lo que se
+/// cantó no se tira — se puede volver a escuchar, afinar o transponer), y lo
+/// que la escucha **entendió** se guarda en el wizard para mostrarlo.
+fn armar_desde_tarareo(model: &mut Model, handle: &Handle<Msg>, clip: AudioClip) {
+ let secs = clip.duration_seconds();
+ let opts = takiy_escucha::OpcionesArmar {
+ estilo: crate::genesis::STYLES[model.inicio.estilo.min(crate::genesis::STYLES.len() - 1)].0,
+ animo: crate::genesis::MOODS[model.inicio.animo.min(crate::genesis::MOODS.len() - 1)].0,
+ compases_por_seccion: model.inicio.compases,
+ seed: model.inicio.seed,
+ partes: Default::default(),
+ tempo_hint: None,
+ ritmo_del_tarareo: model.inicio.ritmo_del_tarareo,
+ window_beats: takiy_escucha::BEATS_POR_COMPAS,
+ };
+ let armado = takiy_escucha::armar(&takiy_escucha::Semilla::Tarareo(clip.clone()), &opts);
+
+ if armado.melodia.is_empty() {
+ // No se le encontró tono a lo grabado: el clip igual entra como
+ // pista, así el trabajo del que cantó no se pierde.
+ let msg = format!("no le encontré tono al tarareo ({secs:.1}s) — te dejo la voz como pista");
+ model.status = msg.clone();
+ agregar_clip_como_pista(model, handle, clip, "voz 1".into(), msg);
+ model.screen = Screen::Overview;
+ return;
+ }
+
+ let resumen = armado.resumen();
+ model.inicio.entendido = Some(resumen.clone());
+ abrir_pieza_generada(model, handle, armado.score, "Tarareo");
+
+ // La voz original, como una pista de onda más al final de la pieza.
+ let n = model.editor.score.tracks().len() + 1;
+ agregar_clip_como_pista(
+ model,
+ handle,
+ clip,
+ format!("voz {n}"),
+ format!("tu tarareo · {secs:.1}s"),
+ );
+ model.status = resumen;
+}
+
/// Describe la pista activa para el modal Escuchar (o "sin pista activa").
fn fuente_activa(model: &Model) -> String {
match model.editor.score.track(model.editor.active_track) {
@@ -117,7 +169,7 @@ fn fuente_activa(model: &Model) -> String {
}
Some(t) if !t.is_empty() => format!("pista «{}» ({} notas)", t.name, t.len()),
Some(t) => format!("pista «{}» (vacía)", t.name),
- None => "sin pista activa".into(),
+ None => t("takiy-no-active-track"),
}
}
@@ -135,9 +187,9 @@ fn correr_escucha(model: &mut Model, handle: &Handle<Msg>, f: crate::escucha::Es
_ => None,
};
let Some(semilla) = semilla else {
- model.status = "la pista activa no tiene melodía ni tarareo".into();
+ model.status = t("takiy-track-no-melody");
push_toast(model, handle, |id| {
- Toast::error(id, "Nada que continuar en la pista activa", TOAST_TTL)
+ Toast::error(id, t("takiy-nothing-to-continue"), TOAST_TTL)
});
return;
};
@@ -145,7 +197,7 @@ fn correr_escucha(model: &mut Model, handle: &Handle<Msg>, f: crate::escucha::Es
if f.ia {
let cfg = crate::llmcfg::config(model.llm_backend, model.llm_model.clone());
model.llm_progress = Some(String::new());
- model.status = "escuchando con IA…".into();
+ model.status = t("takiy-listening-ia");
let h = handle.clone();
handle.spawn(move || {
let cont = takiy_escucha_llm::continuar_blocking_cfg(
@@ -172,7 +224,7 @@ fn correr_escucha(model: &mut Model, handle: &Handle<Msg>, f: crate::escucha::Es
if f.ia {
let cfg = crate::llmcfg::config(model.llm_backend, model.llm_model.clone());
model.llm_progress = Some(String::new());
- model.status = "descifrando con IA…".into();
+ model.status = t("takiy-deciphering-ia");
let h = handle.clone();
handle.spawn(move || {
let ded = takiy_escucha_llm::deducir_blocking_cfg(cfg.as_ref(), &pieza, |c| {
@@ -192,7 +244,7 @@ fn correr_escucha(model: &mut Model, handle: &Handle<Msg>, f: crate::escucha::Es
fn aplicar_continuacion(model: &mut Model, handle: &Handle<Msg>, cont: takiy_escucha::Continuacion) {
model.llm_progress = None;
let n_notas = cont.melodia.len();
- let mut mel = Track::new("Melodía (IA)");
+ let mut mel = Track::new(t("takiy-melody-ia"));
for n in &cont.melodia {
mel.add(*n);
}
@@ -254,6 +306,44 @@ fn ensure_player(model: &mut Model) {
}
}
+/// Guarda la canción de acordes junto a los proyectos. Se llama al salir
+/// de la pantalla y al volcarla al piano roll: la idea (acordes y ritmos
+/// propios) no se pierde por cerrar la app.
+fn guardar_acordes(model: &mut Model) {
+ let dir = model.proy_dir.clone();
+ if let Err(e) = crate::acordes::guardar(&mut model.acordes, &dir) {
+ model.status = format!("error guardando la canción de acordes: {e}");
+ }
+}
+
+/// Toca la progresión de la pantalla Acordes **en loop** (o la detiene si
+/// ya estaba sonando). El loop es el punto: una progresión de cuatro
+/// acordes se juzga escuchándola dar la vuelta, no una vez.
+fn tocar_progresion(model: &mut Model) {
+ ensure_player(model);
+ let Some(player) = model.player.as_ref() else {
+ return;
+ };
+ if model.playing {
+ player.stop();
+ model.playing = false;
+ model.status = format!("detenido · {}", model.acordes.resumen());
+ return;
+ }
+ let score = model.acordes.score();
+ if score.tracks().iter().all(|t| t.is_empty()) {
+ model.status = t("takiy-no-chords-yet");
+ return;
+ }
+ let buf = render_score(&score, model.sf2.as_ref(), player.sample_rate());
+ let frames = buf.frames() as u64;
+ let secs = buf.duration_seconds();
+ model.playback_bpm = score.tempo_bpm;
+ player.play_with(buf, PlayOpts { start_sample: 0, loop_range: Some((0, frames)) });
+ model.playing = true;
+ model.status = format!("en loop · {secs:.1}s · {}", model.acordes.resumen());
+}
+
/// Beat actual de una toma de grabación según el reloj real.
fn rec_beat(rec: &RecState) -> f32 {
rec.started_at.elapsed().as_secs_f32() * rec.bpm / 60.0
@@ -276,7 +366,7 @@ fn start_recording(model: &mut Model) {
count: 0,
last_beat: 0.0,
});
- model.status = "● grabando — toca con el teclado (z s x d c v…)".into();
+ model.status = t("takiy-recording");
// Fondo desde beat 0 y re-ancla el reloj al instante del play.
start_recording_backing(model, 0.0);
if let Some(rec) = model.recording.as_mut() {
@@ -422,6 +512,124 @@ pub(crate) fn actualizar(mut model: Model, msg: Msg, handle: &Handle<Msg>) -> Mo
Msg::Quit => {
handle.quit();
}
+ // --- Inicio: el wizard de arranque -----------------------------
+ Msg::InicioOpen => {
+ model.inicio.paso = crate::inicio::Paso::Menu;
+ model.screen = Screen::Inicio;
+ model.status = "¿cómo empezamos?".into();
+ }
+ Msg::InicioCamino(i) => match i {
+ // Tarareo y acordes tienen preparación; los otros tres despachan
+ // al camino que ya existía.
+ 0 => model.inicio.paso = crate::inicio::Paso::Tarareo,
+ 1 => model.inicio.paso = crate::inicio::Paso::Acordes,
+ 2 => {
+ model.screen = Screen::Overview;
+ return actualizar(model, Msg::GenesisOpen, handle);
+ }
+ 3 => {
+ model.screen = Screen::Overview;
+ return actualizar(model, Msg::WavImportOpen, handle);
+ }
+ _ => {
+ // Lienzo en blanco: un proyecto nuevo, vacío.
+ model.inicio.paso = crate::inicio::Paso::Menu;
+ return actualizar(model, Msg::ProyectoNuevo, handle);
+ }
+ },
+ Msg::InicioVolver => model.inicio.paso = crate::inicio::Paso::Menu,
+ Msg::InicioSeguir(i) => {
+ model.screen = Screen::Overview;
+ return actualizar(model, Msg::ProyectoSwitch(i), handle);
+ }
+ Msg::InicioSetEstilo(i) => model.inicio.estilo = i.min(crate::genesis::STYLES.len() - 1),
+ Msg::InicioSetAnimo(i) => model.inicio.animo = i.min(crate::genesis::MOODS.len() - 1),
+ Msg::InicioCompases(d) => {
+ let n = model.inicio.compases as i32 + d;
+ model.inicio.compases = n.clamp(2, 32) as usize;
+ }
+ Msg::InicioSetRitmo(v) => model.inicio.ritmo_del_tarareo = v,
+ Msg::InicioSetTonica(i) => {
+ model.inicio.tonica = i.min(takiy_acordes::paleta::RAICES.len() - 1)
+ }
+ Msg::InicioSetModo(v) => model.inicio.menor = v,
+ Msg::InicioSetCadencia(i) => {
+ model.inicio.cadencia = i.min(takiy_acordes::progresion::CADENCIAS.len() - 1)
+ }
+ Msg::InicioAcordesIr => {
+ use takiy_acordes::paleta::{Paleta, TipoTonalidad, RAICES};
+ let paleta = Paleta::nueva(
+ RAICES[model.inicio.tonica.min(RAICES.len() - 1)],
+ if model.inicio.menor { TipoTonalidad::Menor } else { TipoTonalidad::Mayor },
+ );
+ let mut cancion = takiy_acordes::Cancion::de_arranque(paleta);
+ cancion.progresion =
+ takiy_acordes::Progresion::cadencia(&paleta, model.inicio.cadencia);
+ model.acordes.banco = cancion.banco();
+ model.acordes.patron = cancion.ajustes.patron;
+ model.acordes.sel = 0;
+ model.acordes.cancion = cancion;
+ model.screen = Screen::Acordes;
+ model.status = model.acordes.resumen();
+ }
+ Msg::InicioTarareoGrabar => match Recorder::start() {
+ Ok(rec) => {
+ let sr = rec.sample_rate();
+ model.voz = Some(crate::voz::VozRec::new(rec));
+ model.status = format!("cantá lo que quieras… (mic {sr} Hz)");
+ }
+ Err(e) => {
+ let msg = format!("sin micrófono: {e}");
+ model.status = msg.clone();
+ push_toast(&mut model, handle, move |id| Toast::error(id, msg, TOAST_TTL));
+ }
+ },
+ Msg::InicioTarareoParar => {
+ let Some(v) = model.voz.take() else {
+ model.status = t("takiy-no-voice-captured");
+ return model;
+ };
+ let (samples, sr) = v.recorder.finish();
+ if samples.is_empty() {
+ model.status = t("takiy-no-voice-captured");
+ push_toast(&mut model, handle, |id| {
+ Toast::error(id, t("takiy-no-voice-captured"), TOAST_TTL)
+ });
+ return model;
+ }
+ let clip = AudioClip::new(samples, sr, 0.0);
+ armar_desde_tarareo(&mut model, handle, clip);
+ }
+ Msg::AcordesToggle => {
+ model.screen = if matches!(model.screen, Screen::Acordes) {
+ guardar_acordes(&mut model);
+ Screen::Overview
+ } else {
+ Screen::Acordes
+ };
+ if matches!(model.screen, Screen::Acordes) {
+ model.status = model.acordes.resumen();
+ }
+ }
+ Msg::Acordes(a) => {
+ match crate::acordes::aplicar(&mut model.acordes, a) {
+ crate::acordes::Sonar::Nada => {
+ model.status = model.acordes.resumen();
+ }
+ crate::acordes::Sonar::Ranura(i) => {
+ if let Some(blip) = crate::acordes::blip_de_ranura(&model.acordes, i) {
+ audition_blip(&mut model, blip);
+ }
+ model.status = model.acordes.resumen();
+ }
+ crate::acordes::Sonar::Progresion => tocar_progresion(&mut model),
+ crate::acordes::Sonar::AlPianoRoll => {
+ let score = model.acordes.score();
+ guardar_acordes(&mut model);
+ abrir_pieza_generada(&mut model, handle, score, "acordes");
+ }
+ }
+ }
Msg::TogglePlay => {
ensure_player(&mut model);
let Some(player) = model.player.as_ref() else {
@@ -478,10 +686,10 @@ pub(crate) fn actualizar(mut model: Model, msg: Msg, handle: &Handle<Msg>) -> Mo
}
}
Msg::Undo => {
- model.status = model.editor.undo().unwrap_or_else(|| "undo vacío".into());
+ model.status = model.editor.undo().unwrap_or_else(|| t("takiy-undo-empty"));
}
Msg::Redo => {
- model.status = model.editor.redo().unwrap_or_else(|| "redo vacío".into());
+ model.status = model.editor.redo().unwrap_or_else(|| t("takiy-redo-empty"));
}
Msg::PasteAtPlayhead => {
let beat = model
@@ -612,7 +820,7 @@ pub(crate) fn actualizar(mut model: Model, msg: Msg, handle: &Handle<Msg>) -> Mo
}
Msg::NudgeProgram { delta } => {
let Some(sf2) = model.sf2.take() else {
- model.status = "sin SF2 — programa no aplica".into();
+ model.status = t("takiy-no-sf2-program");
return model;
};
let track_idx = model.editor.active_track;
@@ -1092,13 +1300,13 @@ pub(crate) fn actualizar(mut model: Model, msg: Msg, handle: &Handle<Msg>) -> Mo
Msg::SidebarLeft(sm) => {
// Selector fijo de proyectos: tras reducir los ejes/buscador/resize,
// el diente del proyecto activo sigue abierto (nunca colapsa).
- model.sidebar_left.update(sm);
+ llimphi_widget_rag_sidebar::persistencia::update_persistente(&mut model.sidebar_left, sm, "takiy", "izq");
model.sidebar_left.open = Some(model.proy_activo as u64);
model.sidebar_left.selected = Some(model.proy_activo as u64);
}
Msg::SidebarRight(sm) => {
// Dock open/collapse: el estado resuelve todo (incluido Activate).
- model.sidebar_right.update(sm);
+ llimphi_widget_rag_sidebar::persistencia::update_persistente(&mut model.sidebar_right, sm, "takiy", "der");
}
Msg::OpenTrack(i) => {
// Abre el editor de la pista clickeada en el panorama (piano
@@ -1112,6 +1320,10 @@ pub(crate) fn actualizar(mut model: Model, msg: Msg, handle: &Handle<Msg>) -> Mo
}
}
Msg::OpenOverview => {
+ // Salir de Acordes con Esc guarda igual que salir por el botón.
+ if matches!(model.screen, Screen::Acordes) {
+ guardar_acordes(&mut model);
+ }
model.screen = Screen::Overview;
model.wave_sel = None;
// Refresca los picos de las pistas en modo onda — pudieron
@@ -1250,7 +1462,7 @@ pub(crate) fn actualizar(mut model: Model, msg: Msg, handle: &Handle<Msg>) -> Mo
let n = model.proyectos[activo].num_versiones();
model.status = format!("versión {n} sellada");
}
- None => model.status = "sin cambios desde la última versión".into(),
+ None => model.status = t("takiy-no-changes"),
}
}
Msg::CheckoutVersion(hash) => {
@@ -1268,7 +1480,7 @@ pub(crate) fn actualizar(mut model: Model, msg: Msg, handle: &Handle<Msg>) -> Mo
// proyecto: recalcular los picos de onda para que se vea.
refresh_onda_peaks(&mut model);
guardar_proyecto_activo(&mut model);
- model.status = "versión restaurada".into();
+ model.status = t("takiy-version-restored");
}
}
Msg::ToastExpire(id) => {
@@ -1279,7 +1491,7 @@ pub(crate) fn actualizar(mut model: Model, msg: Msg, handle: &Handle<Msg>) -> Mo
Some(v) => {
let (samples, sr) = v.recorder.finish();
if samples.is_empty() {
- model.status = "voz: no se capturó audio".into();
+ model.status = t("takiy-no-voice-captured");
} else {
let secs = samples.len() as f32 / sr.max(1) as f32;
// Ancla en beat 0 (MVP). Afinar la posición es un pendiente.
@@ -1328,7 +1540,7 @@ pub(crate) fn actualizar(mut model: Model, msg: Msg, handle: &Handle<Msg>) -> Mo
model.onda_peaks.insert(idx, peaks);
model.status = format!("voz {s:+.0} semitonos");
}
- None => model.status = "la pista activa no tiene voz".into(),
+ None => model.status = t("takiy-track-no-voice"),
}
}
Msg::VozFormant(delta) => {
@@ -1348,7 +1560,7 @@ pub(crate) fn actualizar(mut model: Model, msg: Msg, handle: &Handle<Msg>) -> Mo
model.onda_peaks.insert(idx, peaks);
model.status = format!("formantes {s:+.0} semitonos");
}
- None => model.status = "la pista activa no tiene voz".into(),
+ None => model.status = t("takiy-track-no-voice"),
}
}
Msg::SetInstrument(kind) => {
@@ -1415,7 +1627,7 @@ pub(crate) fn actualizar(mut model: Model, msg: Msg, handle: &Handle<Msg>) -> Mo
// Fue un tap → abrir el editor de la sección.
return actualizar(model, Msg::SectionEditOpen(sd.section), handle);
}
- model.status = "límite de sección movido".into();
+ model.status = t("takiy-section-moved");
}
}
_ => {}
@@ -1442,7 +1654,7 @@ pub(crate) fn actualizar(mut model: Model, msg: Msg, handle: &Handle<Msg>) -> Mo
Msg::SectionRename => {
if let Some(f) = model.secedit.take() {
let nombre = f.name.text();
- let nombre = if nombre.trim().is_empty() { "sección".to_string() } else { nombre };
+ let nombre = if nombre.trim().is_empty() { t("takiy-section") } else { nombre };
if let Some(sec) = model.editor.score.sections.get_mut(f.index) {
sec.name = nombre.clone();
model.status = format!("sección renombrada: {nombre}");
@@ -1471,7 +1683,7 @@ pub(crate) fn actualizar(mut model: Model, msg: Msg, handle: &Handle<Msg>) -> Mo
f.index + 1,
takiy_core::SectionMarker { name: "nueva".into(), start_beat: start },
);
- model.status = "sección añadida".into();
+ model.status = t("takiy-section-added");
}
}
}
@@ -1512,7 +1724,7 @@ pub(crate) fn actualizar(mut model: Model, msg: Msg, handle: &Handle<Msg>) -> Mo
// el progreso; cae al mapeo determinista sin credenciales.
let cfg = crate::llmcfg::config(model.llm_backend, model.llm_model.clone());
model.llm_progress = Some(String::new());
- model.status = "generando instrumento con IA…".into();
+ model.status = t("takiy-generating-instrument");
let h = handle.clone();
handle.spawn(move || {
let patch = takiy_genesis_llm::patch_from_prompt_blocking_streaming_cfg(
@@ -1573,12 +1785,12 @@ pub(crate) fn actualizar(mut model: Model, msg: Msg, handle: &Handle<Msg>) -> Mo
let peaks = crate::overview::compute_onda_peaks(&model.editor.score, idx);
model.onda_peaks.insert(idx, peaks);
model.status = if hard {
- "voz afinada · duro (snap instantáneo)".into()
+ t("takiy-voice-tuned-hard")
} else {
"voz afinada · suave (glissa natural)".into()
};
} else {
- model.status = "la pista activa no tiene voz".into();
+ model.status = t("takiy-track-no-voice");
}
}
// --- Génesis (panel generativo por IA) ---
@@ -1710,7 +1922,7 @@ pub(crate) fn actualizar(mut model: Model, msg: Msg, handle: &Handle<Msg>) -> Mo
let seed = f.seed;
let cfg = crate::llmcfg::config(model.llm_backend, model.llm_model.clone());
model.llm_progress = Some(String::new());
- model.status = "generando con IA…".into();
+ model.status = t("takiy-generating-ia");
let h = handle.clone();
handle.spawn(move || {
let brief = takiy_genesis_llm::brief_from_prompt_blocking_streaming_cfg(
@@ -1723,14 +1935,14 @@ pub(crate) fn actualizar(mut model: Model, msg: Msg, handle: &Handle<Msg>) -> Mo
});
} else {
let score = takiy_genesis::compose(&f.brief());
- abrir_pieza_generada(&mut model, handle, score);
+ abrir_pieza_generada(&mut model, handle, score, &t("takiy-genesis"));
}
}
}
Msg::GenesisComposeBrief(brief) => {
model.llm_progress = None;
let score = takiy_genesis::compose(&brief);
- abrir_pieza_generada(&mut model, handle, score);
+ abrir_pieza_generada(&mut model, handle, score, &t("takiy-genesis"));
}
Msg::LlmProgress(chunk) => {
if let Some(p) = model.llm_progress.as_mut() {
@@ -1770,7 +1982,7 @@ pub(crate) fn actualizar(mut model: Model, msg: Msg, handle: &Handle<Msg>) -> Mo
if let Some(f) = model.wavimport.take() {
let ruta = f.path.text();
if ruta.trim().is_empty() {
- model.status = "importar WAV: ruta vacía".into();
+ model.status = t("takiy-wav-empty-path");
} else {
return actualizar(model, Msg::ImportWav(ruta.trim().into()), handle);
}
@@ -1903,6 +2115,21 @@ fn selected_pitch(model: &Model) -> Option<u8> {
/// el tempo congelado a 120 bpm — la duración real en segundos es
/// `AUDITION_BEATS · 60 / 120 = 0.25 s`.
fn audition_pitch(model: &mut Model, pitch_midi: u8) {
+ let Some(pitch) = Pitch::from_midi(pitch_midi) else {
+ return;
+ };
+ let mut blip = Score::new(120.0);
+ let mut track = Track::new("audition");
+ track.add(ScoreNote::new(pitch, 0.0, AUDITION_BEATS, 96));
+ blip.add_track(track);
+ audition_blip(model, blip);
+}
+
+/// Toca un `Score` cortito como audición: mismo camino de render que el
+/// playback (osc o SF2), sin pisar una reproducción en curso y con el
+/// mismo throttle. Lo comparten el blip de una nota del piano roll y el
+/// acorde que se escucha al tocarlo en la pantalla Acordes.
+fn audition_blip(model: &mut Model, blip: Score) {
let Some(player) = model.player.as_ref() else {
return;
};
@@ -1915,13 +2142,7 @@ fn audition_pitch(model: &mut Model, pitch_midi: u8) {
return;
}
}
- let Some(pitch) = Pitch::from_midi(pitch_midi) else {
- return;
- };
- let mut blip = Score::new(120.0);
- let mut track = Track::new("audition");
- track.add(ScoreNote::new(pitch, 0.0, AUDITION_BEATS, 96));
- let track_idx = blip.add_track(track);
+ let track_idx = 0;
// Si la pista activa del editor tiene un programa GM mapeable y hay
// SF2 cargado, propagamos ese programa al renderer del blip para
// que el timbre sea consistente con la pista que se está editando.
diff --git a/02_ruway/takiy/takiy-app-llimphi/src/voz.rs b/02_ruway/takiy/takiy-app-llimphi/src/voz.rs
index 1bb53b6..32514c6 100644
--- a/02_ruway/takiy/takiy-app-llimphi/src/voz.rs
+++ b/02_ruway/takiy/takiy-app-llimphi/src/voz.rs
@@ -112,8 +112,8 @@ pub(crate) fn overlay(level: f32, seconds: f32, theme: &Theme) -> View<Msg> {
flex_direction: FlexDirection::Row,
size: Size { width: Dimension::auto(), height: length(28.0_f32) },
flex_shrink: 0.0,
- align_items: Some(AlignItems::Center),
- justify_content: Some(JustifyContent::Center),
+ align_items: Some(AlignItems::CENTER),
+ justify_content: Some(JustifyContent::CENTER),
gap: Size { width: length(6.0_f32), height: length(0.0_f32) },
padding: Rect {
left: length(12.0_f32),
@@ -137,7 +137,7 @@ pub(crate) fn overlay(level: f32, seconds: f32, theme: &Theme) -> View<Msg> {
flex_direction: FlexDirection::Row,
size: Size { width: Dimension::auto(), height: length(44.0_f32) },
flex_shrink: 0.0,
- align_items: Some(AlignItems::Center),
+ align_items: Some(AlignItems::CENTER),
gap: Size { width: length(12.0_f32), height: length(0.0_f32) },
padding: Rect {
left: length(16.0_f32),
@@ -155,8 +155,8 @@ pub(crate) fn overlay(level: f32, seconds: f32, theme: &Theme) -> View<Msg> {
// Anclado arriba-centro, con un poco de aire desde el borde.
View::new(Style {
size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
- align_items: Some(AlignItems::Center),
- justify_content: Some(JustifyContent::Start),
+ align_items: Some(AlignItems::CENTER),
+ justify_content: Some(JustifyContent::START),
flex_direction: FlexDirection::Column,
padding: Rect {
left: length(0.0_f32),
diff --git a/02_ruway/takiy/takiy-app-llimphi/src/waveedit.rs b/02_ruway/takiy/takiy-app-llimphi/src/waveedit.rs
index 3e614d3..d3b8a4f 100644
--- a/02_ruway/takiy/takiy-app-llimphi/src/waveedit.rs
+++ b/02_ruway/takiy/takiy-app-llimphi/src/waveedit.rs
@@ -25,6 +25,7 @@ use takiy_core::WaveOp;
use crate::appmodel::Model;
use crate::msg::Msg;
use crate::overview::PALETTE;
+use rimay_localize::t;
/// Eje de tiempo del editor (igual que `update::wave_total_beats`).
fn total_beats(model: &Model) -> f32 {
@@ -44,7 +45,7 @@ pub(crate) fn body(model: &Model, theme: &Theme) -> View<Msg> {
let name = track.map(|t| t.name.clone()).unwrap_or_default();
let sel_label = match model.wave_sel {
Some((a, b)) if (b - a).abs() > 1e-3 => format!("sel [{a:.2}, {b:.2}) beats"),
- _ => "sin selección — ops a toda la pista (arrastra sobre la onda)".to_string(),
+ _ => t("takiy-no-selection-whole-track"),
};
let ops = ops_bar(active, from, to, &name, &sel_label, theme);
@@ -82,7 +83,7 @@ fn ops_bar(
flex_direction: FlexDirection::Row,
size: Size { width: percent(1.0_f32), height: length(34.0_f32) },
flex_shrink: 0.0,
- align_items: Some(AlignItems::Center),
+ align_items: Some(AlignItems::CENTER),
gap: Size { width: length(6.0_f32), height: length(0.0_f32) },
padding: Rect {
left: length(8.0_f32),
@@ -98,7 +99,7 @@ fn ops_bar(
let info = View::new(Style {
size: Size { width: percent(1.0_f32), height: length(20.0_f32) },
flex_shrink: 0.0,
- align_items: Some(AlignItems::Center),
+ align_items: Some(AlignItems::CENTER),
padding: Rect {
left: length(10.0_f32),
right: length(10.0_f32),
diff --git a/02_ruway/takiy/takiy-app-llimphi/src/wavimport.rs b/02_ruway/takiy/takiy-app-llimphi/src/wavimport.rs
index df647b4..c6bde1b 100644
--- a/02_ruway/takiy/takiy-app-llimphi/src/wavimport.rs
+++ b/02_ruway/takiy/takiy-app-llimphi/src/wavimport.rs
@@ -50,8 +50,8 @@ pub(crate) fn overlay(form: &WavImportForm, theme: &Theme) -> View<Msg> {
flex_direction: FlexDirection::Row,
size: Size { width: percent(1.0_f32), height: length(30.0_f32) },
flex_shrink: 0.0,
- align_items: Some(AlignItems::Center),
- justify_content: Some(JustifyContent::End),
+ align_items: Some(AlignItems::CENTER),
+ justify_content: Some(JustifyContent::END),
gap: Size { width: length(8.0_f32), height: length(0.0_f32) },
margin: Rect {
left: length(0.0_f32),
@@ -75,7 +75,7 @@ fn labeled(theme: &Theme, label: &str, control: View<Msg>) -> View<Msg> {
let slot = View::new(Style {
flex_grow: 1.0,
size: Size { width: percent(0.0_f32), height: length(30.0_f32) },
- align_items: Some(AlignItems::Center),
+ align_items: Some(AlignItems::CENTER),
..Default::default()
})
.children(vec![control]);
diff --git a/02_ruway/takiy/takiy-core/src/chord.rs b/02_ruway/takiy/takiy-core/src/chord.rs
index 2f967bf..c349edc 100644
--- a/02_ruway/takiy/takiy-core/src/chord.rs
+++ b/02_ruway/takiy/takiy-core/src/chord.rs
@@ -14,6 +14,19 @@ pub enum ChordQuality {
Major7,
Minor7,
Dominant7,
+ /// Suspendidas: la tercera se reemplaza por la segunda o la cuarta.
+ Sus2,
+ Sus4,
+ /// Bicordio de quinta («power chord»): raíz + quinta, sin tercera.
+ Power5,
+ Sixth,
+ MinorSixth,
+ Add9,
+ Diminished7,
+ /// Semidisminuido (`m7b5`).
+ HalfDiminished,
+ Dominant9,
+ Minor9,
}
impl ChordQuality {
@@ -27,6 +40,16 @@ impl ChordQuality {
ChordQuality::Major7 => &[0, 4, 7, 11],
ChordQuality::Minor7 => &[0, 3, 7, 10],
ChordQuality::Dominant7 => &[0, 4, 7, 10],
+ ChordQuality::Sus2 => &[0, 2, 7],
+ ChordQuality::Sus4 => &[0, 5, 7],
+ ChordQuality::Power5 => &[0, 7],
+ ChordQuality::Sixth => &[0, 4, 7, 9],
+ ChordQuality::MinorSixth => &[0, 3, 7, 9],
+ ChordQuality::Add9 => &[0, 4, 7, 14],
+ ChordQuality::Diminished7 => &[0, 3, 6, 9],
+ ChordQuality::HalfDiminished => &[0, 3, 6, 10],
+ ChordQuality::Dominant9 => &[0, 4, 7, 10, 14],
+ ChordQuality::Minor9 => &[0, 3, 7, 10, 14],
}
}
@@ -40,6 +63,16 @@ impl ChordQuality {
ChordQuality::Major7 => "maj7",
ChordQuality::Minor7 => "m7",
ChordQuality::Dominant7 => "7",
+ ChordQuality::Sus2 => "sus2",
+ ChordQuality::Sus4 => "sus4",
+ ChordQuality::Power5 => "5",
+ ChordQuality::Sixth => "6",
+ ChordQuality::MinorSixth => "m6",
+ ChordQuality::Add9 => "add9",
+ ChordQuality::Diminished7 => "dim7",
+ ChordQuality::HalfDiminished => "m7b5",
+ ChordQuality::Dominant9 => "9",
+ ChordQuality::Minor9 => "m9",
}
}
}
@@ -118,6 +151,26 @@ mod tests {
assert!(!g7.contains(Pitch::from_class_octave(PitchClass::E, 5).unwrap()));
}
+ #[test]
+ fn sus_y_potencia_no_tienen_tercera() {
+ for q in [ChordQuality::Sus2, ChordQuality::Sus4, ChordQuality::Power5] {
+ let iv = q.intervals();
+ assert!(!iv.contains(&3) && !iv.contains(&4), "{q:?} no debe traer tercera");
+ assert!(iv.contains(&7), "{q:?} debe traer quinta");
+ }
+ }
+
+ #[test]
+ fn la_novena_suena_una_octava_arriba() {
+ // Cadd9 = C E G D(5) — la novena es un RE de la octava siguiente,
+ // no un re pegado a la raíz.
+ let voces = Chord::new(PitchClass::C, ChordQuality::Add9).voicing(4);
+ assert_eq!(voces.len(), 4);
+ let novena = voces[3];
+ assert_eq!(novena.class(), PitchClass::D);
+ assert_eq!(novena.midi() - voces[0].midi(), 14);
+ }
+
#[test]
fn names_render_correctly() {
assert_eq!(Chord::new(PitchClass::C, ChordQuality::Major).name(), "C");
diff --git a/02_ruway/takiy/takiy-escucha/LEEME.md b/02_ruway/takiy/takiy-escucha/LEEME.md
index 3bd00dc..909956d 100644
--- a/02_ruway/takiy/takiy-escucha/LEEME.md
+++ b/02_ruway/takiy/takiy-escucha/LEEME.md
@@ -2,7 +2,7 @@
*Read this in English: [README.md](README.md).*
-Las dos **escuchas** de takiy: en vez de *generar* música desde una intención
+Las **escuchas** de takiy: en vez de *generar* música desde una intención
(eso es `takiy-genesis`), aquí se **parte de sonido ya dado** y se lo interpreta.
Es el espejo de `genesis`, y como él es **agnóstico de UI** y **determinista**
(misma semilla → misma continuación), sin síntesis ni modelos: sólo DSP propio y
@@ -40,6 +40,32 @@ let d = deducir(&Pieza::Score(score));
// d.acordes, d.ritmo (LoopRitmo::a_track lo reconstruye), d.tempo_bpm
```
+## Escucha 3 — `armar` (hacé la canción)
+
+De un tarareo a una pieza **entera**. Es la que contesta la pregunta con la
+que alguien abre takiy por primera vez —*«canté esto, hacé la canción»*—; las
+otras dos escuchas son sus partes.
+
+```rust
+use takiy_escucha::{armar, OpcionesArmar, Semilla};
+let a = armar(&Semilla::Tarareo(clip), &OpcionesArmar::default());
+println!("{}", a.resumen()); // «C mayor · 96 BPM · C Am F G · 6 secciones»
+// a.score ya trae melodía + acordes + bajo + batería, con forma.
+```
+
+El camino es el de un arreglador: transcribe el tarareo (YIN), le busca
+tonalidad y pulso, lo **armoniza** contra esa tonalidad
+(`chords::armonizar_diatonico` — una voz sola no da acordes, da una línea, y
+el emparejamiento libre inventa acordes ajenos a la tonalidad), y con eso
+llena el `takiy_genesis::PlanArreglo` que fija progresión, melodía y pulso;
+el motor generativo pone la forma (intro/verso/estribillo/…), el bajo, la
+batería y los timbres.
+
+El tarareo no «inspira» la melodía: **es** la melodía de la pieza, compás por
+compás. Y `Armado` devuelve todo lo que dedujo para llegar ahí (tonalidad,
+tempo, progresión, ritmo, secciones, el brief) — es lo que la UI muestra para
+que el que cantó pueda corregir sin volver a cantar.
+
## DSP propio
`dsp.rs` — YIN (tono monofónico), envolvente de onset, Goertzel/chroma y energía
diff --git a/02_ruway/takiy/takiy-escucha/README.md b/02_ruway/takiy/takiy-escucha/README.md
index 6a3c478..82b45d9 100644
--- a/02_ruway/takiy/takiy-escucha/README.md
+++ b/02_ruway/takiy/takiy-escucha/README.md
@@ -1,6 +1,6 @@
# takiy-escucha
-takiy's two **listenings**: instead of *generating* music from an intent (that is
+takiy's **listenings**: instead of *generating* music from an intent (that is
`takiy-genesis`), here it **starts from sound already given** and interprets it.
It is genesis's mirror and, like it, **UI-agnostic** and **deterministic** (same
@@ -9,6 +9,13 @@ theory over `takiy-core`.
Listening 1 — `continuar` (follow the thread).
+Listening 2 — `deducir` (decipher): chords + rhythm loop out of a piece.
+
+Listening 3 — `armar` (make the song): from a hummed melody to a whole
+multi-track piece — key, pulse, harmony, song form, bass, drums and timbres.
+It is the one that answers the question someone opens takiy with; the other
+two are its parts.
+
---
Part of **takiy** — see [takiy](../README.md).
diff --git a/02_ruway/takiy/takiy-escucha/src/armar.rs b/02_ruway/takiy/takiy-escucha/src/armar.rs
new file mode 100644
index 0000000..f0a9f99
--- /dev/null
+++ b/02_ruway/takiy/takiy-escucha/src/armar.rs
@@ -0,0 +1,325 @@
+//! Escucha 3 — **armar**: de un tarareo a una canción entera.
+//!
+//! Las otras dos escuchas contestan preguntas parciales: [`crate::continuar`]
+//! sigue el hilo unos compases, [`crate::deducir`] descifra acordes y ritmo.
+//! Ninguna de las dos contesta la pregunta con la que alguien abre takiy por
+//! primera vez: *«canté esto, hacé la canción»*. Eso es lo que arma aquí.
+//!
+//! El camino es el que haría un arreglador: transcribe el tarareo, le busca
+//! la tonalidad y el pulso, lo **armoniza** contra esa tonalidad (una voz
+//! sola no da acordes, da una línea — ver
+//! [`crate::chords::armonizar_diatonico`]), y con eso llena el
+//! [`takiy_genesis::PlanArreglo`] que fija progresión, melodía y pulso; el
+//! motor de `takiy-genesis` pone la forma (intro/verso/estribillo/…), el
+//! bajo, la batería y los timbres. El tarareo no se «inspira»: **es** la
+//! melodía de la pieza, compás por compás.
+//!
+//! Determinista como todo el dominio: mismo tarareo + misma semilla → la
+//! misma canción.
+
+use takiy_core::{Scale, Score, ScoreNote, Track};
+use takiy_genesis::{
+ brief::{Mood, Parts, ScaleKind, Style},
+ Brief, PlanArreglo, Section,
+};
+
+use crate::{chords, dsp, groove, key, pitch_track, tempo, AcordeEnTiempo, LoopRitmo, Semilla};
+use crate::{Contexto, BEATS_POR_COMPAS};
+
+/// Cómo armar la canción alrededor de lo escuchado. Los defaults son la
+/// respuesta razonable para alguien que sólo tarareó: pop, ánimo brillante,
+/// ocho compases por sección y el pulso medido al propio tarareo.
+#[derive(Debug, Clone, Copy)]
+pub struct OpcionesArmar {
+ /// Estilo — decide la **forma** (qué secciones y en qué orden) y el
+ /// groove del acompañamiento.
+ pub estilo: Style,
+ /// Ánimo — registro, densidad y dinámica.
+ pub animo: Mood,
+ /// Compases de cada sección principal (intro/outro van a la mitad).
+ pub compases_por_seccion: usize,
+ /// Semilla de lo que el motor completa (bajo, relleno, variación).
+ pub seed: u64,
+ /// Qué partes suenan.
+ pub partes: Parts,
+ /// Tempo forzado en BPM; `None` = el que se le mida al tarareo.
+ pub tempo_hint: Option<f32>,
+ /// Usar el ritmo **medido** al tarareo (sus propios acentos) en vez del
+ /// groove de fábrica del estilo. Con una voz poco percusiva conviene
+ /// apagarlo.
+ pub ritmo_del_tarareo: bool,
+ /// Ventana de armonización en beats: cada cuánto puede cambiar el acorde
+ /// (un compás por defecto; 2.0 = dos acordes por compás).
+ pub window_beats: f32,
+}
+
+impl Default for OpcionesArmar {
+ fn default() -> Self {
+ Self {
+ estilo: Style::Pop,
+ animo: Mood::Brillante,
+ compases_por_seccion: 8,
+ seed: 1,
+ partes: Parts::default(),
+ tempo_hint: None,
+ ritmo_del_tarareo: true,
+ window_beats: BEATS_POR_COMPAS,
+ }
+ }
+}
+
+/// Lo que la escucha 3 devuelve: la canción, y **todo lo que dedujo para
+/// llegar a ella**. Lo segundo importa tanto como lo primero: es lo que la
+/// UI muestra para que el que tarareó entienda qué le entendieron, y pueda
+/// corregirlo (cambiar la tonalidad, el tempo, un acorde) sin volver a
+/// cantar.
+#[derive(Debug, Clone)]
+pub struct Armado {
+ /// Tonalidad y tempo inferidos, y dónde termina lo que se cantó.
+ pub contexto: Contexto,
+ /// El tarareo transcrito a notas, desde el beat 0.
+ pub melodia: Track,
+ /// La progresión armonizada (un acorde por ventana) que se repite.
+ pub acordes: Vec<AcordeEnTiempo>,
+ /// El pulso medido al tarareo, si se midió.
+ pub ritmo: Option<LoopRitmo>,
+ /// La forma de la canción (qué secciones y de cuántos compases).
+ pub secciones: Vec<Section>,
+ /// El brief con el que se compuso — editable para rehacerla distinta.
+ pub brief: Brief,
+ /// La pieza multipista lista para editar y sonar.
+ pub score: Score,
+}
+
+impl Armado {
+ /// Resumen de una línea para el estado de la UI: «Do mayor · 96 BPM ·
+ /// C Am F G · 5 secciones».
+ pub fn resumen(&self) -> String {
+ let modo = if es_menor(&self.contexto.key) { "menor" } else { "mayor" };
+ let prog: Vec<String> = self.acordes.iter().take(8).map(|a| a.chord.name()).collect();
+ format!(
+ "{} {modo} · {} BPM · {} · {} secciones",
+ self.contexto.key.root().name(),
+ self.contexto.tempo_bpm.round(),
+ prog.join(" "),
+ self.secciones.len()
+ )
+ }
+}
+
+/// `true` si la escala tiene tercera menor (tres semitonos sobre la raíz).
+fn es_menor(scale: &Scale) -> bool {
+ let root = scale.root().semitone() as i32;
+ let tercera = scale.degree(2).semitone() as i32;
+ (tercera - root).rem_euclid(12) == 3
+}
+
+/// Arma la canción entera a partir de lo escuchado.
+pub fn armar(semilla: &Semilla, opts: &OpcionesArmar) -> Armado {
+ // --- 1. De la semilla a notas y pulso ---------------------------------
+ let (notas, tempo_bpm, ritmo) = match semilla {
+ Semilla::Tarareo(clip) => {
+ let bpm = opts.tempo_hint.unwrap_or_else(|| {
+ let hop = (clip.sample_rate as usize / 100).max(1);
+ let env = dsp::onset_envelope(&clip.samples, clip.sample_rate, hop);
+ tempo::inferir_tempo(&env)
+ });
+ let notas = pitch_track::tararear_a_notas(clip, bpm);
+ let ritmo = opts
+ .ritmo_del_tarareo
+ .then(|| groove::deducir_ritmo_audio(clip, bpm, 16))
+ .filter(|r| r.bombo.iter().chain(&r.caja).chain(&r.charles).any(|&h| h));
+ (notas, bpm, ritmo)
+ }
+ Semilla::Melodia(track) => {
+ let bpm = opts.tempo_hint.unwrap_or(96.0);
+ (track.notes().to_vec(), bpm, None)
+ }
+ Semilla::Acordes(acordes) => {
+ // Sin línea que cantar: la melodía la pone el motor y los acordes
+ // dados son la progresión.
+ let bpm = opts.tempo_hint.unwrap_or(96.0);
+ let notas: Vec<ScoreNote> = acordes
+ .iter()
+ .flat_map(|a| {
+ a.chord
+ .voicing(4)
+ .into_iter()
+ .map(move |p| ScoreNote::new(p, a.beat, BEATS_POR_COMPAS, 80))
+ })
+ .collect();
+ (notas, bpm, None)
+ }
+ };
+
+ // --- 2. Tonalidad ------------------------------------------------------
+ let escala = key::inferir_tonalidad(&key::perfil_de_notas(¬as));
+
+ // --- 3. Armonía: la progresión que sostiene lo cantado -----------------
+ let fin = notas.iter().map(|n| n.start + n.duration).fold(0.0_f32, f32::max);
+ let compases = ((fin / BEATS_POR_COMPAS).ceil() as usize).max(1);
+ let acordes = match semilla {
+ Semilla::Acordes(dados) if !dados.is_empty() => dados.clone(),
+ _ => chords::armonizar_diatonico(
+ ¬as,
+ &escala,
+ opts.window_beats,
+ compases as f32 * BEATS_POR_COMPAS,
+ ),
+ };
+
+ // --- 4. El brief: lo inferido más lo que el usuario eligió -------------
+ let brief = Brief {
+ root: escala.root(),
+ scale: if es_menor(&escala) { ScaleKind::Menor } else { ScaleKind::Mayor },
+ tempo_bpm,
+ bars: opts.compases_por_seccion.max(2),
+ mood: opts.animo,
+ style: opts.estilo,
+ seed: opts.seed,
+ parts: opts.partes,
+ };
+
+ // --- 5. El plan: lo escuchado manda sobre lo que el motor inventaría ---
+ let mut melodia = Track::new("Melodía");
+ for n in ¬as {
+ melodia.add(*n);
+ }
+ let melodia_dada = (!melodia.is_empty() && !matches!(semilla, Semilla::Acordes(_)))
+ .then(|| melodia.clone());
+ let bateria_dada = ritmo.as_ref().map(|r| r.a_track(1));
+ let plan = PlanArreglo {
+ prog_a: Some(acordes.iter().map(|a| a.chord).collect()),
+ prog_b: None,
+ melodia: melodia_dada,
+ bateria: bateria_dada,
+ };
+
+ let score = takiy_genesis::compose_con(&brief, &plan);
+ let secciones = takiy_genesis::arrangement(&brief);
+
+ Armado {
+ contexto: Contexto { key: escala, tempo_bpm, fin_fragmento: compases as f32 * BEATS_POR_COMPAS },
+ melodia,
+ acordes,
+ ritmo,
+ secciones,
+ brief,
+ score,
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use takiy_core::{AudioClip, Pitch};
+
+ /// Un tarareo sintético: una senoidal que salta entre pitches dados, un
+ /// beat cada uno, a `bpm`. Es la fuente barata con la que se certifica la
+ /// escucha sin grabar a nadie.
+ fn tarareo(midis: &[u8], bpm: f32, sr: u32) -> AudioClip {
+ let seg_por_nota = 60.0 / bpm;
+ let n = (seg_por_nota * sr as f32) as usize;
+ let mut samples = Vec::with_capacity(n * midis.len());
+ let mut fase = 0.0_f32;
+ for &m in midis {
+ let f = Pitch::from_midi(m).unwrap().frequency();
+ for i in 0..n {
+ // Envolvente suave para que el detector de onsets vea el ataque.
+ let t = i as f32 / n as f32;
+ let env = (1.0 - t).powf(0.6);
+ fase += std::f32::consts::TAU * f / sr as f32;
+ samples.push(fase.sin() * 0.6 * env);
+ }
+ }
+ AudioClip::new(samples, sr, 0.0)
+ }
+
+ #[test]
+ fn de_una_melodia_sale_una_cancion_con_forma() {
+ // Un arpegio que ancla Do mayor y vuelve a la tónica: do-mi-sol-do,
+ // sol-mi-do-do. (Una escala que sube y baja sin resolver NO establece
+ // tonalidad — Krumhansl la lee por su nota más pesada, y con razón.)
+ let mut t = Track::new("tarareo");
+ for (i, m) in [60_u8, 64, 67, 72, 67, 64, 60, 60].iter().enumerate() {
+ t.add(ScoreNote::new(Pitch::from_midi(*m).unwrap(), i as f32, 1.0, 100));
+ }
+ let a = armar(&Semilla::Melodia(t), &OpcionesArmar::default());
+
+ // Cuatro pistas: la canción entera, no un fragmento.
+ assert_eq!(a.score.tracks().len(), 4, "melodía + acordes + bajo + batería");
+ // Forma de pop: seis secciones.
+ assert_eq!(a.secciones.len(), 6, "intro/verso/estribillo/verso/estribillo/outro");
+ assert_eq!(a.score.sections.len(), 6, "los marcadores llegan al score");
+ // Y dura mucho más que lo cantado (2 compases).
+ assert!(
+ a.score.duration_beats() > 2.0 * BEATS_POR_COMPAS * 5.0,
+ "la canción tiene que crecer: {} beats",
+ a.score.duration_beats()
+ );
+ // La tonalidad inferida es Do mayor y los acordes son de esa casa.
+ assert_eq!(a.contexto.key.root().name(), "C");
+ assert!(!es_menor(&a.contexto.key));
+ assert_eq!(a.acordes.len(), 2, "un acorde por compás cantado");
+ }
+
+ #[test]
+ fn la_melodia_de_la_cancion_es_la_que_se_canto() {
+ let mut t = Track::new("tarareo");
+ for i in 0..4 {
+ t.add(ScoreNote::new(Pitch::from_midi(60).unwrap(), i as f32, 1.0, 100));
+ }
+ let a = armar(&Semilla::Melodia(t), &OpcionesArmar::default());
+ let mel = a.score.tracks().iter().find(|x| x.name == "Melodía").unwrap();
+ // Todo Do (y su transposición en el estribillo): el motor no inventó
+ // una línea propia encima.
+ let clases: std::collections::HashSet<u8> =
+ mel.notes().iter().map(|n| n.pitch.midi() % 12).collect();
+ assert!(clases.len() <= 2, "la melodía no es la cantada: {clases:?}");
+ }
+
+ #[test]
+ fn los_acordes_armonizados_pertenecen_a_la_tonalidad() {
+ // Una línea en La menor (la-do-mi-la).
+ let mut t = Track::new("tarareo");
+ for (i, m) in [57_u8, 60, 64, 69, 67, 64, 60, 57].iter().enumerate() {
+ t.add(ScoreNote::new(Pitch::from_midi(*m).unwrap(), i as f32, 1.0, 100));
+ }
+ let a = armar(&Semilla::Melodia(t), &OpcionesArmar::default());
+ let escala = &a.contexto.key;
+ for ac in &a.acordes {
+ let grados: Vec<_> = (0..7).map(|d| escala.degree(d)).collect();
+ assert!(
+ grados.contains(&ac.chord.root),
+ "el acorde {} es ajeno a la tonalidad {:?}",
+ ac.chord.name(),
+ escala.root().name()
+ );
+ }
+ }
+
+ #[test]
+ fn un_tarareo_de_verdad_llega_a_cancion() {
+ // PCM sintético a 22050 Hz — el camino completo: YIN, tempo, chroma.
+ let clip = tarareo(&[60, 62, 64, 65, 64, 62, 60, 60], 100.0, 22_050);
+ let a = armar(&Semilla::Tarareo(clip), &OpcionesArmar::default());
+ assert!(!a.melodia.is_empty(), "el tarareo tiene que transcribirse a notas");
+ assert!(!a.acordes.is_empty(), "y tiene que tener acordes");
+ assert_eq!(a.score.tracks().len(), 4);
+ assert!(a.contexto.tempo_bpm > 30.0 && a.contexto.tempo_bpm < 300.0);
+ assert!(a.resumen().contains("BPM"));
+ }
+
+ #[test]
+ fn misma_semilla_misma_cancion() {
+ let mut t = Track::new("tarareo");
+ for (i, m) in [60_u8, 64, 67, 64].iter().enumerate() {
+ t.add(ScoreNote::new(Pitch::from_midi(*m).unwrap(), i as f32, 1.0, 100));
+ }
+ let a = armar(&Semilla::Melodia(t.clone()), &OpcionesArmar::default());
+ let b = armar(&Semilla::Melodia(t), &OpcionesArmar::default());
+ for (ta, tb) in a.score.tracks().iter().zip(b.score.tracks()) {
+ assert_eq!(ta.notes(), tb.notes(), "la escucha 3 tiene que ser determinista");
+ }
+ }
+}
diff --git a/02_ruway/takiy/takiy-escucha/src/chords.rs b/02_ruway/takiy/takiy-escucha/src/chords.rs
index 56be73e..81e936a 100644
--- a/02_ruway/takiy/takiy-escucha/src/chords.rs
+++ b/02_ruway/takiy/takiy-escucha/src/chords.rs
@@ -151,6 +151,64 @@ pub fn deducir_acordes_audio(
out
}
+/// **Armoniza** una melodía suelta contra una tonalidad: para cada ventana
+/// elige, entre los siete acordes diatónicos, el que mejor cubre las notas
+/// que suenan ahí.
+///
+/// Es lo que hace falta cuando la fuente es un **tarareo**: una voz sola no
+/// da acordes, da una línea, y el emparejamiento libre de [`acorde_de_chroma`]
+/// contra las dos o tres clases de un compás inventa acordes ajenos a la
+/// tonalidad (un tarareo en Do que toca mi-sol se lee «Mi menor» tan bien
+/// como «Do mayor»). Anclando a la escala, cualquier resultado suena — que
+/// es la promesa de takiy para el que no sabe teoría.
+///
+/// Las ventanas sin nota heredan el acorde anterior (o la tónica al empezar),
+/// así la progresión no tiene huecos.
+pub fn armonizar_diatonico(
+ notes: &[ScoreNote],
+ scale: &takiy_core::Scale,
+ window_beats: f32,
+ hasta_beat: f32,
+) -> Vec<AcordeEnTiempo> {
+ let window = if window_beats > 0.0 { window_beats } else { BEATS_POR_COMPAS };
+ let candidatos: Vec<Chord> = (0..7)
+ .map(|d| takiy_genesis::harmony::diatonic_chord(scale, d, false))
+ .collect();
+ let tonica = candidatos[0];
+ let mut out = Vec::new();
+ let mut beat = 0.0_f32;
+ let mut ultimo = tonica;
+ while beat < hasta_beat - 1e-3 {
+ let ch = chroma_de_notas(notes, beat, beat + window);
+ let total: f32 = ch.iter().sum();
+ let elegido = if total <= 0.0 {
+ ultimo
+ } else {
+ let mut mejor = (f32::MIN, tonica);
+ for (i, c) in candidatos.iter().enumerate() {
+ let mut p = 0.0;
+ for (j, iv) in c.quality.intervals().iter().enumerate() {
+ // La fundamental pesa más que la tercera y la quinta: es
+ // la que decide qué acorde se oye.
+ let peso = if j == 0 { 1.5 } else { 1.0 };
+ let clase = (c.root.semitone() as usize + *iv as usize) % 12;
+ p += ch[clase] * peso;
+ }
+ // Empate → gana el grado más bajo (I antes que vi): en duda,
+ // la casa manda.
+ if p > mejor.0 + 1e-6 {
+ mejor = (p, candidatos[i]);
+ }
+ }
+ mejor.1
+ };
+ out.push(AcordeEnTiempo::new(beat, elegido));
+ ultimo = elegido;
+ beat += window;
+ }
+ out
+}
+
/// Reconstruye una pista de **pads** a partir de una progresión: cada acorde
/// suena como bloque desde su beat hasta el siguiente (el último dura una
/// ventana). Sirve para escuchar/editar lo deducido.
diff --git a/02_ruway/takiy/takiy-escucha/src/lib.rs b/02_ruway/takiy/takiy-escucha/src/lib.rs
index a09c0fb..d02d3fd 100644
--- a/02_ruway/takiy/takiy-escucha/src/lib.rs
+++ b/02_ruway/takiy/takiy-escucha/src/lib.rs
@@ -1,4 +1,4 @@
-//! `takiy-escucha` — las dos **escuchas** de takiy: en vez de *generar*
+//! `takiy-escucha` — las **escuchas** de takiy: en vez de *generar*
//! música desde una intención (eso es [`takiy_genesis`]), aquí se **parte
//! de sonido ya dado** y se lo interpreta.
//!
@@ -10,6 +10,11 @@
//! - **Escucha 2 — [`deducir`]** («descifrar»): de una pieza —un
//! [`Score`] nativo o **audio** PCM— deduce la **secuencia de acordes**
//! y el **loop de ritmo** (patrón de batería + tempo).
+//! - **Escucha 3 — [`armar`]** («hacé la canción»): de un tarareo a una
+//! pieza **entera** — tonalidad, pulso, armonía, forma (intro/verso/
+//! estribillo/…), bajo, batería y timbres. Es la que contesta la
+//! pregunta con la que alguien abre takiy por primera vez; las otras dos
+//! son sus partes.
//!
//! Como todo takiy, es **agnóstico de UI** y **determinista**: no hay
//! síntesis ni modelos aquí, sólo DSP propio (YIN, envolvente de onset,
@@ -22,6 +27,7 @@
#![forbid(unsafe_code)]
+pub mod armar;
pub mod chords;
pub mod continuar;
pub mod deducir;
@@ -34,6 +40,7 @@ pub mod tempo;
use serde::{Deserialize, Serialize};
use takiy_core::{Chord, Scale, ScoreNote};
+pub use armar::{armar, Armado, OpcionesArmar};
pub use continuar::{continuar, continuar_con_plan, PlanContinuacion};
pub use deducir::deducir;
diff --git a/02_ruway/takiy/takiy-genesis/src/arrange.rs b/02_ruway/takiy/takiy-genesis/src/arrange.rs
index 9eadbb5..06a23fe 100644
--- a/02_ruway/takiy/takiy-genesis/src/arrange.rs
+++ b/02_ruway/takiy/takiy-genesis/src/arrange.rs
@@ -54,17 +54,82 @@ fn lift_notes(track: &Track, semis: i32) -> Track {
out
}
+/// Largo en compases que ocupa una pista (redondeado hacia arriba, mínimo 1).
+fn compases_de(track: &Track) -> usize {
+ let fin = track.notes().iter().map(|n| n.start + n.duration).fold(0.0_f32, f32::max);
+ ((fin / BEATS_POR_COMPAS).ceil() as usize).max(1)
+}
+
+/// Repite `src` (un motivo de `largo` compases, escrito desde el compás 0)
+/// para cubrir `bars` compases a partir de `bar_offset`. Lo que se pase del
+/// final de la sección se recorta. Es lo que permite que una melodía **dada**
+/// —un tarareo transcrito— ocupe el lugar que el motor le habría inventado.
+fn tile_notes(src: &Track, bar_offset: usize, bars: usize, nombre: &str) -> Track {
+ let mut out = Track::new(nombre.to_string());
+ if src.is_empty() || bars == 0 {
+ return out;
+ }
+ let largo = compases_de(src);
+ let fin_sec = (bar_offset + bars) as f32 * BEATS_POR_COMPAS;
+ let repes = bars.div_ceil(largo);
+ for k in 0..repes {
+ let desp = (bar_offset + k * largo) as f32 * BEATS_POR_COMPAS;
+ for n in src.notes() {
+ let start = n.start + desp;
+ if start >= fin_sec {
+ continue;
+ }
+ let dur = n.duration.min(fin_sec - start);
+ if dur > 0.0 {
+ out.add(ScoreNote::new(n.pitch, start, dur, n.velocity));
+ }
+ }
+ }
+ out
+}
+
+/// Piezas ya decididas **afuera** del motor que reemplazan lo que éste
+/// inventaría. Es el mango con el que la escucha (`takiy-escucha`) arma una
+/// canción entera alrededor de un tarareo: la progresión sale del tarareo, la
+/// melodía **es** el tarareo, y el pulso es el que se le midió. Todo campo en
+/// `None` cae a lo generado — con el plan vacío, [`compose_con`] es
+/// exactamente [`compose`], nota por nota.
+#[derive(Debug, Clone, Default)]
+pub struct PlanArreglo {
+ /// Progresión del verso (loop de acordes). `None` = la genera el motor.
+ pub prog_a: Option<Vec<Chord>>,
+ /// Progresión del estribillo. `None` = la genera el motor.
+ pub prog_b: Option<Vec<Chord>>,
+ /// Melodía dada: se repite para cubrir cada sección que lleve melodía,
+ /// en vez de componer una. Se escribe desde el compás 0.
+ pub melodia: Option<Track>,
+ /// Loop de batería dado (escrito desde el compás 0): se repite en las
+ /// secciones con batería en vez de generar el groove del estilo.
+ pub bateria: Option<Track>,
+}
+
/// Compone una pieza completa **con estructura de secciones** (intro/verso/
/// estribillo/…). Determinista: mismo brief (incluida la semilla) → mismo
/// `Score`. Cada sección decide qué partes tocan, con qué densidad/dinámica y
/// si usan la progresión de verso o de estribillo.
pub fn compose(brief: &Brief) -> Score {
+ compose_con(brief, &PlanArreglo::default())
+}
+
+/// Como [`compose`], pero con un [`PlanArreglo`] que puede fijar la
+/// progresión, la melodía o el loop de batería en vez de inventarlos. El
+/// consumo del RNG es idéntico en ambos caminos (lo generado se calcula
+/// igual y se descarta), así el resto de las partes no se mueve por fijar
+/// una sola.
+pub fn compose_con(brief: &Brief, plan: &PlanArreglo) -> Score {
let scale = brief.scale();
let mut rng = Rng::new(brief.seed);
// Dos loops armónicos: verso (A) y estribillo (B), para dar contraste.
- let prog_a = base_progression(brief, &mut rng);
- let prog_b = base_progression(brief, &mut rng);
+ let gen_a = base_progression(brief, &mut rng);
+ let gen_b = base_progression(brief, &mut rng);
+ let prog_a = plan.prog_a.clone().filter(|p| !p.is_empty()).unwrap_or(gen_a);
+ let prog_b = plan.prog_b.clone().filter(|p| !p.is_empty()).unwrap_or(gen_b);
let sections = arrangement(brief);
let mood_density = brief.mood.density();
@@ -92,7 +157,12 @@ pub fn compose(brief: &Brief) -> Score {
let lift = prof.lift_semitones;
if let Some(t) = melody.as_mut() {
if prof.melodia {
- let s = melody_section(brief, &chords, bar_cursor, density, prof.vel_scale, &mut rng);
+ let generada =
+ melody_section(brief, &chords, bar_cursor, density, prof.vel_scale, &mut rng);
+ let s = match plan.melodia.as_ref() {
+ Some(dada) => tile_notes(dada, bar_cursor, sec.bars, "Melodía"),
+ None => generada,
+ };
merge(t, &lift_notes(&s, lift));
}
}
@@ -109,10 +179,14 @@ pub fn compose(brief: &Brief) -> Score {
}
if let Some(t) = bateria.as_mut() {
if prof.bateria {
- let s = drum_section(
+ let generada = drum_section(
brief, sec.bars, bar_cursor, prof.vel_scale, prof.thin_drums,
crash_first, fill_last, &mut rng,
);
+ let s = match plan.bateria.as_ref() {
+ Some(loop_dado) => tile_notes(loop_dado, bar_cursor, sec.bars, "Batería"),
+ None => generada,
+ };
merge(t, &s);
}
}
@@ -162,6 +236,52 @@ mod tests {
use super::*;
use crate::brief::{Brief, Parts};
+ #[test]
+ fn el_plan_vacio_no_cambia_una_nota() {
+ // La garantía que sostiene todo lo demás: agregar el mango no movió
+ // el motor. Mismo brief, plan vacío → la misma pieza nota por nota.
+ for seed in [1_u64, 7, 42] {
+ let brief = Brief { seed, ..Brief::default() };
+ let a = compose(&brief);
+ let b = compose_con(&brief, &PlanArreglo::default());
+ assert_eq!(a.tracks().len(), b.tracks().len());
+ for (ta, tb) in a.tracks().iter().zip(b.tracks()) {
+ assert_eq!(ta.name, tb.name);
+ assert_eq!(ta.notes(), tb.notes(), "pista {} con semilla {seed}", ta.name);
+ }
+ }
+ }
+
+ #[test]
+ fn la_melodia_dada_reemplaza_a_la_inventada_y_no_mueve_el_resto() {
+ use takiy_core::{Pitch, ScoreNote, Track};
+ let brief = Brief::default();
+ let base = compose(&brief);
+
+ // Un motivo de un compás: cuatro negras en Do4.
+ let mut tarareo = Track::new("Tarareo");
+ for i in 0..4 {
+ tarareo.add(ScoreNote::new(Pitch::from_midi(60).unwrap(), i as f32, 1.0, 100));
+ }
+ let plan = PlanArreglo { melodia: Some(tarareo), ..Default::default() };
+ let con = compose_con(&brief, &plan);
+
+ // La melodía cambió: es toda Do4 (el motivo dado), salvo el lift del
+ // estribillo, que la transpone entera.
+ let mel = con.tracks().iter().find(|t| t.name == "Melodía").unwrap();
+ assert!(!mel.is_empty(), "la melodía dada tiene que sonar");
+ let clases: std::collections::HashSet<u8> =
+ mel.notes().iter().map(|n| n.pitch.midi() % 12).collect();
+ assert!(clases.len() <= 2, "sólo la clase del motivo (y su lift), vino {clases:?}");
+
+ // Y el resto de las partes no se movió ni una nota.
+ for nombre in ["Acordes", "Bajo", "Batería"] {
+ let a = base.tracks().iter().find(|t| t.name == nombre).unwrap();
+ let b = con.tracks().iter().find(|t| t.name == nombre).unwrap();
+ assert_eq!(a.notes(), b.notes(), "fijar la melodía movió {nombre}");
+ }
+ }
+
#[test]
fn pieza_completa_tiene_cuatro_pistas() {
let score = compose(&Brief::default());
diff --git a/02_ruway/takiy/takiy-genesis/src/lib.rs b/02_ruway/takiy/takiy-genesis/src/lib.rs
index 4e60a05..e811219 100644
--- a/02_ruway/takiy/takiy-genesis/src/lib.rs
+++ b/02_ruway/takiy/takiy-genesis/src/lib.rs
@@ -31,7 +31,7 @@ pub mod rhythm;
pub mod rng;
pub mod sections;
-pub use arrange::compose;
+pub use arrange::{compose, compose_con, PlanArreglo};
pub use brief::{Brief, Mood, Parts, ScaleKind, Style};
pub use instrument::patch_from_prompt;
pub use rng::Rng;
diff --git a/02_ruway/takiy/takiy-karaoke-llimphi/Cargo.toml b/02_ruway/takiy/takiy-karaoke-llimphi/Cargo.toml
new file mode 100644
index 0000000..ece1df5
--- /dev/null
+++ b/02_ruway/takiy/takiy-karaoke-llimphi/Cargo.toml
@@ -0,0 +1,29 @@
+[package]
+name = "takiy-karaoke-llimphi"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+authors.workspace = true
+publish.workspace = true
+description = "takiy-karaoke-llimphi — la sala de karaoke: una base, la letra barrida sílaba a sílaba, el micrófono abierto y el puntaje al final. La letra la lee media-core (LRC/ASS con \\k); la voz la graba, afina y mezcla takiy."
+
+[lib]
+name = "takiy_karaoke_app"
+path = "src/lib.rs"
+
+[[bin]]
+name = "takiy-karaoke-llimphi"
+path = "src/main.rs"
+
+[dependencies]
+# Los textos de pantalla salen del catálogo de idioma, no del fuente.
+rimay-localize = { workspace = true }
+takiy-karaoke = { path = "../takiy-karaoke" }
+takiy-core = { path = "../takiy-core" }
+takiy-synth = { path = "../takiy-synth" }
+takiy-playback = { path = "../takiy-playback" }
+takiy-tanda = { path = "../takiy-tanda" }
+media-core = { workspace = true }
+llimphi-ui = { workspace = true }
+llimphi-theme = { workspace = true }
+llimphi-widget-progress = { workspace = true }
diff --git a/02_ruway/takiy/takiy-karaoke-llimphi/LEEME.md b/02_ruway/takiy/takiy-karaoke-llimphi/LEEME.md
new file mode 100644
index 0000000..50a75e2
--- /dev/null
+++ b/02_ruway/takiy/takiy-karaoke-llimphi/LEEME.md
@@ -0,0 +1,24 @@
+# takiy-karaoke-llimphi
+
+*Read this in English: [README.md](README.md).*
+
+> La sala de karaoke: una base, la letra barrida sílaba a sílaba, el micrófono abierto y el
+> puntaje al final.
+
+La letra la lee `media-core` (LRC/ASS con `\k`); la voz la graba, afina y mezcla takiy.
+
+La **mitad biblioteca** tiene la sesión sin UI y sin device de audio: funciones puras sobre
+datos — cargar la base y la letra, decidir qué PCM se toca (con voz o sin ella), cortar la
+línea que se está cantando en «lo cantado / la sílaba de ahora / lo que falta», y cerrar la
+toma (medir el retardo, afinar, mezclar, puntuar).
+
+Esa partición es lo que permite probar la sala sin placa de sonido.
+
+```bash
+cargo run -p takiy-karaoke-llimphi --release
+cargo test -p takiy-karaoke-llimphi # sala
+```
+
+---
+
+Parte de **takiy** — ver [takiy](../LEEME.md).
diff --git a/02_ruway/takiy/takiy-karaoke-llimphi/README.md b/02_ruway/takiy/takiy-karaoke-llimphi/README.md
new file mode 100644
index 0000000..32530ee
--- /dev/null
+++ b/02_ruway/takiy/takiy-karaoke-llimphi/README.md
@@ -0,0 +1,23 @@
+# takiy-karaoke-llimphi
+
+> The karaoke room: a backing track, the lyric swept syllable by syllable, the microphone
+> open, and the score at the end.
+
+The lyric is read by `media-core` (LRC/ASS with `\k`); the voice is recorded, tuned and
+mixed by takiy.
+
+The **library half** holds the session with no UI and no audio device: pure functions over
+data — load the backing and the lyric, decide which PCM plays (with or without the vocal),
+cut the line being sung into "sung / this syllable / still to come", and close the take
+(measure the delay, tune, mix, score).
+
+That split is what lets the room be tested without a sound card.
+
+```bash
+cargo run -p takiy-karaoke-llimphi --release
+cargo test -p takiy-karaoke-llimphi # sala
+```
+
+---
+
+Part of **takiy** — see [takiy](../README.md).
diff --git a/02_ruway/takiy/takiy-karaoke-llimphi/src/lib.rs b/02_ruway/takiy/takiy-karaoke-llimphi/src/lib.rs
new file mode 100644
index 0000000..82a6d8d
--- /dev/null
+++ b/02_ruway/takiy/takiy-karaoke-llimphi/src/lib.rs
@@ -0,0 +1,389 @@
+//! `takiy-karaoke-llimphi` (lib) — la **sesión** de karaoke, sin UI y sin
+//! device de audio.
+//!
+//! Todo lo que decide qué se ve y qué se oye vive acá, en funciones puras
+//! sobre datos: cargar la base y la letra, decidir qué PCM se toca (con
+//! voz o sin ella), cortar la línea que se está cantando en «lo cantado /
+//! la sílaba de ahora / lo que falta», y cerrar la toma (medir el retardo,
+//! afinar, mezclar, puntuar).
+//!
+//! El binario de al lado sólo abre la ventana y el micrófono.
+
+#![forbid(unsafe_code)]
+
+use std::path::Path;
+
+use media_core::karaoke::{LineaKaraoke, PistaKaraoke};
+use media_core::subtitles::SubtitleTrack;
+use rimay_localize::t;
+use takiy_karaoke::{
+ centro, detectar_offset, melodia_de_audio, mezclar, puntuar, sin_voz, NotaEsperada, Puntaje,
+};
+
+/// Corte de graves que se le devuelve a la base al cancelar el centro.
+const BAJOS_HZ: f32 = 120.0;
+/// Corte de agudos al aislar el centro para sacar la melodía de referencia.
+const AGUDOS_HZ: f32 = 150.0;
+/// Hasta dónde se busca el retardo de la toma.
+const MAX_RETARDO_MS: u32 = 400;
+/// Pulsos de la cuenta de entrada.
+const CUENTA_PULSOS: u32 = 4;
+/// Segundos por pulso de la cuenta (0,5 = 120 bpm).
+///
+/// No es un gusto: el retardo se mide correlacionando la toma con lo que
+/// sonó, y una señal **periódica** correlaciona igual de bien en cada
+/// múltiplo de su período. Con los clicks separados 500 ms y la búsqueda
+/// acotada a [`MAX_RETARDO_MS`], el segundo candidato queda fuera de la
+/// ventana y el pico es único. Si se acortara por debajo de 400 ms, la
+/// medición volvería a tener dos respuestas — y entonces no mediría.
+const CUENTA_SEG_POR_PULSO: f32 = 0.5;
+
+/// En qué está la sala.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum Estado {
+ /// Base cargada, nadie cantando.
+ Listo,
+ /// Sonando la base y el micrófono abierto.
+ Cantando,
+ /// Terminó la toma: hay mezcla y puntaje.
+ Revisando,
+}
+
+/// Una sesión de karaoke.
+pub struct Sesion {
+ /// La base tal como vino, si era estéreo (para poder sacarle la voz).
+ estereo: Option<Vec<f32>>,
+ /// La base en mono, tal cual (con voz).
+ mono: Vec<f32>,
+ /// La base sin el centro (con voz cancelada). Se calcula al cargar.
+ sin_centro: Option<Vec<f32>>,
+ pub sample_rate: u32,
+ /// La letra sincronizada. Vacía si no había archivo.
+ pub letra: PistaKaraoke,
+ /// La melodía de referencia para puntuar (sale del centro del tema).
+ pub melodia: Vec<NotaEsperada>,
+ pub estado: Estado,
+ /// `true` = se toca la base sin voz.
+ pub quitar_voz: bool,
+ /// Fuerza de afinación automática de la toma, `0..=1`.
+ pub afinar: f32,
+ /// La última toma cruda, como salió del micrófono.
+ pub voz: Vec<f32>,
+ /// Retardo medido de esa toma, en ms.
+ pub offset_ms: i64,
+ /// `true` si el retardo se midió; `false` si es el valor por defecto.
+ pub offset_medido: bool,
+ /// La mezcla resultante.
+ pub mezcla: Vec<f32>,
+ pub puntaje: Option<Puntaje>,
+ /// Nombre del archivo de base, para el cartel.
+ pub titulo: String,
+}
+
+impl Sesion {
+ /// Carga una base (`.wav`) y, si viene, su letra (`.lrc`, `.ass`,
+ /// `.srt`, `.vtt`). Sin letra la sala igual funciona: se canta de
+ /// memoria y se puntúa contra la melodía del tema.
+ pub fn cargar(base: &Path, letra: Option<&Path>) -> Result<Sesion, String> {
+ let f = std::fs::File::open(base).map_err(|e| rimay_localize::t_args("takiy-karaoke-cannot-open", &[("ruta", base.display().to_string().into()), ("e", e.to_string().into())]))?;
+ let buf = takiy_tanda::cosecha::leer_wav_de(f)
+ .ok_or_else(|| format!("{} no es un WAV PCM16/f32 legible", base.display()))?;
+ let sr = buf.sample_rate;
+ let (estereo, mono) = if buf.channels == 2 {
+ let mono = buf
+ .samples
+ .chunks_exact(2)
+ .map(|c| (c[0] + c[1]) * 0.5)
+ .collect();
+ (Some(buf.samples.clone()), mono)
+ } else {
+ (None, buf.samples.clone())
+ };
+
+ let sin_centro = estereo.as_ref().map(|e| sin_voz(e, sr, BAJOS_HZ));
+ // La referencia para puntuar sale del centro del tema — donde
+ // está la voz original. Mono: no se puede aislar, se usa entero.
+ let referencia = match &estereo {
+ Some(e) => centro(e, sr, AGUDOS_HZ),
+ None => mono.clone(),
+ };
+ let melodia = melodia_de_audio(&referencia, sr);
+
+ let letra = match letra {
+ Some(p) => cargar_letra(p)?,
+ None => PistaKaraoke::default(),
+ };
+
+ let titulo = letra
+ .titulo
+ .clone()
+ .or_else(|| base.file_stem().map(|s| s.to_string_lossy().to_string()))
+ .unwrap_or_else(|| "karaoke".into());
+
+ Ok(Sesion {
+ estereo,
+ mono,
+ sin_centro,
+ sample_rate: sr,
+ letra,
+ melodia,
+ estado: Estado::Listo,
+ // Si la base es estéreo se arranca sin voz (es un karaoke);
+ // si es mono no hay nada que cancelar.
+ quitar_voz: true,
+ afinar: 0.0,
+ voz: Vec::new(),
+ offset_ms: 0,
+ offset_medido: false,
+ mezcla: Vec::new(),
+ puntaje: None,
+ titulo,
+ })
+ }
+
+ /// Duración de la base en segundos.
+ pub fn duracion_s(&self) -> f32 {
+ if self.sample_rate == 0 {
+ return 0.0;
+ }
+ self.mono.len() as f32 / self.sample_rate as f32
+ }
+
+ /// `true` si la base es estéreo (y entonces se le puede sacar la voz).
+ pub fn tiene_estereo(&self) -> bool {
+ self.estereo.is_some()
+ }
+
+ /// El PCM mono que hay que tocar ahora mismo.
+ pub fn base_a_tocar(&self) -> &[f32] {
+ match (self.quitar_voz, &self.sin_centro) {
+ (true, Some(s)) => s,
+ _ => &self.mono,
+ }
+ }
+
+ /// Cuánto dura la cuenta de entrada, en segundos.
+ pub fn cuenta_s(&self) -> f32 {
+ CUENTA_PULSOS as f32 * CUENTA_SEG_POR_PULSO
+ }
+
+ /// Muestras que ocupa la cuenta de entrada.
+ pub fn cuenta_muestras(&self) -> usize {
+ takiy_synth::count_in_samples(self.sample_rate, CUENTA_SEG_POR_PULSO, CUENTA_PULSOS)
+ }
+
+ /// Lo que realmente se manda al parlante: **la cuenta de entrada y
+ /// después la base**. Los clicks no son cortesía — son la señal
+ /// contra la que se mide el retardo de la toma (ver
+ /// [`CUENTA_SEG_POR_PULSO`]) y, de paso, lo que le dice al que canta
+ /// cuándo entrar.
+ pub fn pcm_a_tocar(&self) -> Vec<f32> {
+ let base = takiy_synth::AudioBuffer::from_mono(self.sample_rate, self.base_a_tocar().to_vec());
+ takiy_synth::prepend_count_in(
+ base,
+ CUENTA_SEG_POR_PULSO,
+ CUENTA_PULSOS,
+ &takiy_synth::Metronome::DEFAULT,
+ )
+ .samples
+ }
+
+ /// Posición dentro de la **canción** (descontada la cuenta), dado el
+ /// reloj del reproductor. Negativa durante la cuenta.
+ pub fn reloj_cancion(&self, t_reproductor: f32) -> f32 {
+ t_reproductor - self.cuenta_s()
+ }
+
+ /// Cierra la toma: mide el retardo contra la base, afina si se pidió,
+ /// mezcla y puntúa. Deja la sesión en [`Estado::Revisando`].
+ ///
+ /// `sr_voz` puede diferir del de la base (el micrófono manda su propia
+ /// tasa): se remuestrea antes de tocar nada.
+ pub fn cerrar_toma(&mut self, voz: Vec<f32>, sr_voz: u32) {
+ let voz = if sr_voz != self.sample_rate && sr_voz > 0 {
+ takiy_core::AudioClip::new(voz, sr_voz, 0.0).resampled(self.sample_rate)
+ } else {
+ voz
+ };
+ self.voz = voz;
+ let sr = self.sample_rate;
+ let sonado = self.pcm_a_tocar();
+ let cuenta_n = self.cuenta_muestras();
+
+ // El retardo se mide **en la cuenta**, no en la canción: ahí el
+ // que canta todavía está callado, así que lo único que el
+ // micrófono tiene es lo que salió del parlante — que es
+ // exactamente lo que hay que alinear. Medio segundo de más por si
+ // la toma arrancó tarde.
+ let ventana = cuenta_n + sr as usize / 2;
+ let hasta_voz = ventana.min(self.voz.len());
+ let hasta_ref = ventana.min(sonado.len());
+ match detectar_offset(&self.voz[..hasta_voz], &sonado[..hasta_ref], sr, MAX_RETARDO_MS) {
+ Some(ms) => {
+ self.offset_ms = ms;
+ self.offset_medido = true;
+ }
+ // Con auriculares el micrófono no oye los clicks y no hay
+ // nada que correlacionar: se deja lo que haya (0, o lo que el
+ // operador haya calibrado a mano) y se avisa que NO se midió.
+ None => self.offset_medido = false,
+ }
+
+ let procesada = if self.afinar > 0.0 {
+ takiy_synth::voice::autotune(&self.voz, sr, None, self.afinar, 40.0)
+ } else {
+ self.voz.clone()
+ };
+
+ // La mezcla se arma sobre lo sonado (con cuenta) y después se le
+ // corta la cuenta: los clicks alinearon, no van al resultado.
+ let entera = mezclar(&sonado, &procesada, sr, self.offset_ms, 0.8, 1.0);
+ self.mezcla = entera.get(cuenta_n..).map(|s| s.to_vec()).unwrap_or_default();
+
+ // La melodía está escrita desde el arranque de la **canción**;
+ // dentro de la toma eso cae después de la cuenta y del retardo.
+ let desfase = self.cuenta_s() + self.offset_ms as f32 / 1000.0;
+ self.puntaje = Some(puntuar(&procesada, sr, &self.melodia, desfase));
+ self.estado = Estado::Revisando;
+ }
+
+ /// Guarda la mezcla como WAV mono.
+ pub fn guardar_mezcla(&self, destino: &Path) -> Result<(), String> {
+ if self.mezcla.is_empty() {
+ return Err(t("takiy-karaoke-no-take"));
+ }
+ let buf = takiy_synth::AudioBuffer::from_mono(self.sample_rate, self.mezcla.clone());
+ takiy_synth::write_wav(&buf, destino)
+ .map_err(|e| rimay_localize::t_args("takiy-karaoke-cannot-write", &[("e", e.to_string().into())]))
+ }
+}
+
+/// Lee la letra de un archivo: LRC/ASS con karaoke van por su parser; un
+/// subtítulo común entra como karaoke pobre (línea entera barrida).
+pub fn cargar_letra(p: &Path) -> Result<PistaKaraoke, String> {
+ let texto = std::fs::read_to_string(p).map_err(|e| rimay_localize::t_args("takiy-karaoke-cannot-open", &[("ruta", p.display().to_string().into()), ("e", e.to_string().into())]))?;
+ if let Ok(k) = PistaKaraoke::parse(&texto) {
+ if k.silabas() > 0 || !k.is_empty() {
+ return Ok(k);
+ }
+ }
+ let sub = SubtitleTrack::parse_subtitles(&texto)
+ .map_err(|e| format!("{} no es una letra ni un subtítulo: {e}", p.display()))?;
+ Ok(PistaKaraoke::desde_pista(&sub))
+}
+
+/// Los dos cortes de la línea que se está cantando, en **bytes** del
+/// texto: `(fin_de_lo_cantado, fin_de_la_sílaba_actual)`.
+///
+/// Con eso la UI pinta tres colores en una sola pasada: lo ya cantado,
+/// la sílaba de ahora (el resalte que se mueve) y lo que falta.
+/// Sin sílabas, el corte se estima por fracción de caracteres — el
+/// karaoke pobre de un subtítulo común.
+pub fn corte(linea: &LineaKaraoke, t: std::time::Duration) -> (usize, usize) {
+ if linea.silabas.is_empty() {
+ let avance = linea.avance(t);
+ let total: usize = linea.texto.chars().count();
+ let hasta = (total as f32 * avance).round() as usize;
+ let b = linea
+ .texto
+ .char_indices()
+ .nth(hasta)
+ .map(|(i, _)| i)
+ .unwrap_or(linea.texto.len());
+ return (b, b);
+ }
+ let mut cursor = 0usize;
+ let mut cantado = 0usize;
+ for s in &linea.silabas {
+ let fin = cursor + s.texto.len();
+ if t < s.start {
+ // Todavía no llegó a esta sílaba.
+ return (cantado, cantado);
+ }
+ if t < s.end {
+ return (cursor, fin);
+ }
+ cantado = fin;
+ cursor = fin;
+ }
+ (cantado, cantado)
+}
+
+/// Texto corto del puntaje para el cartel final.
+pub fn veredicto(p: &Puntaje) -> String {
+ let nota = p.total.round() as i32;
+ let comentario = match nota {
+ 90..=i32::MAX => t("takiy-karaoke-pitch-perfect"),
+ 75..=89 => t("takiy-karaoke-pitch-great"),
+ 55..=74 => t("takiy-karaoke-pitch-ok"),
+ 30..=54 => t("takiy-karaoke-pitch-weak"),
+ _ => t("takiy-karaoke-pitch-again"),
+ };
+ let tendencia = if p.cobertura < 0.2 {
+ String::from(t("takiy-karaoke-pitch-silent"))
+ } else if p.desvio_cents > 25.0 {
+ rimay_localize::t_args("takiy-karaoke-sharp-by", &[("cents", format!("{:.0}", p.desvio_cents).into())])
+ } else if p.desvio_cents < -25.0 {
+ rimay_localize::t_args("takiy-karaoke-flat-by", &[("cents", format!("{:.0}", -p.desvio_cents).into())])
+ } else {
+ String::from(t("takiy-karaoke-centered"))
+ };
+ format!("{nota} — {comentario}, {tendencia}")
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use media_core::karaoke::Silaba;
+ use std::time::Duration;
+
+ fn ms(n: u64) -> Duration {
+ Duration::from_millis(n)
+ }
+
+ #[test]
+ fn corte_marca_la_silaba_de_ahora() {
+ let l = LineaKaraoke::con_silabas(vec![
+ Silaba::new("Ho", ms(0), ms(500)),
+ Silaba::new("la", ms(500), ms(800)),
+ Silaba::new(" mundo", ms(800), ms(2000)),
+ ]);
+ assert_eq!(corte(&l, ms(0)), (0, 2)); // "Ho" activa
+ assert_eq!(corte(&l, ms(600)), (2, 4)); // "la" activa, "Ho" cantado
+ assert_eq!(corte(&l, ms(900)), (4, 10)); // " mundo"
+ assert_eq!(corte(&l, ms(5000)), (10, 10)); // todo cantado
+ }
+
+ #[test]
+ fn corte_sin_silabas_reparte_por_caracteres() {
+ let l = LineaKaraoke::plana(ms(0), ms(1000), "abcdefghij");
+ assert_eq!(corte(&l, ms(0)), (0, 0));
+ assert_eq!(corte(&l, ms(500)), (5, 5));
+ assert_eq!(corte(&l, ms(1000)), (10, 10));
+ }
+
+ #[test]
+ fn corte_no_parte_un_caracter_multibyte() {
+ let l = LineaKaraoke::plana(ms(0), ms(1000), "ñañañañañ");
+ // Todos los cortes tienen que caer en frontera de carácter.
+ for t in (0..=1000).step_by(37) {
+ let (a, b) = corte(&l, ms(t));
+ assert!(l.texto.is_char_boundary(a), "corte {a} parte un carácter");
+ assert!(l.texto.is_char_boundary(b));
+ }
+ }
+
+ #[test]
+ fn veredicto_dice_para_donde_desafinas() {
+ let p = Puntaje {
+ por_nota: Vec::new(),
+ total: 92.0,
+ desvio_cents: -40.0,
+ cobertura: 0.9,
+ };
+ let v = veredicto(&p);
+ assert!(v.contains("92") && v.contains("afinadísimo") && v.contains("abajo"), "{v}");
+ let mudo = Puntaje { por_nota: Vec::new(), total: 0.0, desvio_cents: 0.0, cobertura: 0.0 };
+ assert!(veredicto(&mudo).contains("casi no se te oyó"));
+ }
+}
diff --git a/02_ruway/takiy/takiy-karaoke-llimphi/src/main.rs b/02_ruway/takiy/takiy-karaoke-llimphi/src/main.rs
new file mode 100644
index 0000000..35c85eb
--- /dev/null
+++ b/02_ruway/takiy/takiy-karaoke-llimphi/src/main.rs
@@ -0,0 +1,458 @@
+// En release sobre Windows: subsistema GUI (sin consola negra detrás).
+#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
+//! `takiy-karaoke-llimphi` — la sala de karaoke.
+//!
+//! ```sh
+//! cargo run -p takiy-karaoke-llimphi --release -- base.wav letra.lrc
+//! ```
+//!
+//! La base suena por el device default; el micrófono queda abierto; la
+//! letra se barre sílaba a sílaba con el reloj del reproductor (no con el
+//! del sistema: si el audio se atrasa, la letra se atrasa con él). Al
+//! cortar, se mide **cuánto llegó tarde la toma** correlacionándola con la
+//! base, se afina si se pidió, se mezcla y se puntúa.
+//!
+//! Teclas:
+//!
+//! - `Espacio` — arranca la toma / corta.
+//! - `V` — base con voz / sin voz (sólo si la base es estéreo).
+//! - `A`/`Z` — sube/baja la afinación automática de la toma.
+//! - `Enter` — escucha la mezcla de la última toma.
+//! - `S` — guarda la mezcla al lado de la base (`*-toma.wav`).
+//! - `Esc` — corta lo que esté sonando.
+
+use std::path::PathBuf;
+use std::sync::Arc;
+use std::time::Duration;
+
+use llimphi_theme::Theme;
+use llimphi_ui::llimphi_layout::taffy::{
+ prelude::{length, percent, FlexDirection, Size, Style},
+ AlignItems, JustifyContent, Rect,
+};
+use llimphi_ui::llimphi_raster::peniko::Color;
+use llimphi_ui::llimphi_text::Alignment;
+use llimphi_ui::{App, Handle, Key, KeyEvent, KeyState, NamedKey, View};
+use llimphi_widget_progress::linear_progress_view;
+use takiy_karaoke_app::{corte, veredicto, Estado, Sesion};
+use takiy_playback::{Player, Recorder};
+use takiy_synth::AudioBuffer;
+
+// ============================================================
+// Modelo
+// ============================================================
+
+struct Model {
+ theme: Theme,
+ /// `Err` = no se pudo cargar nada: la ventana explica por qué.
+ sesion: Result<Sesion, String>,
+ player: Option<Arc<Player>>,
+ /// Micrófono abierto mientras dure la toma.
+ rec: Option<Recorder>,
+ /// Segundos de la base ya reproducidos (reloj del player).
+ t: f32,
+ /// Pico reciente del micrófono, para el medidor.
+ nivel: f32,
+ /// Última línea de estado (guardado, errores, aviso de retardo).
+ aviso: String,
+ /// Destino sugerido para guardar la toma.
+ destino: PathBuf,
+}
+
+#[derive(Clone)]
+enum Msg {
+ Tick,
+ Alternar,
+ QuitarVoz,
+ Afinar(f32),
+ Escuchar,
+ Guardar,
+ Parar,
+}
+
+struct Karaoke;
+
+impl App for Karaoke {
+ type Model = Model;
+ type Msg = Msg;
+
+ fn init(handle: &Handle<Msg>) -> Model {
+ handle.spawn_periodic(Duration::from_millis(33), || Msg::Tick);
+
+ let args: Vec<String> = std::env::args().skip(1).collect();
+ let sesion = if args.is_empty() {
+ Err("uso: takiy-karaoke-llimphi <base.wav> [letra.lrc|.ass|.srt]".to_string())
+ } else {
+ let base = PathBuf::from(&args[0]);
+ let letra = args.get(1).map(PathBuf::from);
+ Sesion::cargar(&base, letra.as_deref())
+ };
+ let destino = args
+ .first()
+ .map(|a| {
+ let p = PathBuf::from(a);
+ let tallo = p.file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_default();
+ p.with_file_name(format!("{tallo}-toma.wav"))
+ })
+ .unwrap_or_else(|| PathBuf::from("toma.wav"));
+
+ let player = Player::open().ok().map(Arc::new);
+ let aviso = if player.is_none() {
+ rimay_localize::t("takiy-karaoke-no-audio-device-long")
+ } else {
+ String::new()
+ };
+
+ Model { theme: Theme::default(), sesion, player, rec: None, t: 0.0, nivel: 0.0, aviso, destino }
+ }
+
+ fn update(mut model: Model, msg: Msg, _handle: &Handle<Msg>) -> Model {
+ match msg {
+ Msg::Tick => {
+ if let Some(p) = &model.player {
+ if p.is_playing() {
+ model.t = p.position_seconds();
+ }
+ }
+ if let Some(r) = &model.rec {
+ model.nivel = r.level();
+ }
+ // La base terminó sola: cerrar la toma como si hubieran
+ // apretado Espacio (si no, el micrófono queda abierto).
+ // El `t > 0.5` evita cerrarla en el primer cuadro, antes
+ // de que el stream de salida arranque de verdad.
+ let termino = model.rec.is_some()
+ && model.t > 0.5
+ && model.player.as_ref().is_some_and(|p| !p.is_playing());
+ if termino {
+ model = cerrar(model);
+ }
+ }
+ Msg::Alternar => {
+ if model.rec.is_some() {
+ model = cerrar(model);
+ } else {
+ model = arrancar(model);
+ }
+ }
+ Msg::QuitarVoz => {
+ if let Ok(s) = &mut model.sesion {
+ if s.tiene_estereo() && s.estado != Estado::Cantando {
+ s.quitar_voz = !s.quitar_voz;
+ model.aviso = if s.quitar_voz {
+ rimay_localize::t("takiy-karaoke-track-instrumental")
+ } else {
+ rimay_localize::t("takiy-karaoke-track-guide")
+ };
+ } else if !s.tiene_estereo() {
+ model.aviso = rimay_localize::t("takiy-karaoke-mono");
+ }
+ }
+ }
+ Msg::Afinar(d) => {
+ if let Ok(s) = &mut model.sesion {
+ s.afinar = (s.afinar + d).clamp(0.0, 1.0);
+ model.aviso = rimay_localize::t_args(
+ "takiy-karaoke-autotune",
+ &[("pct", format!("{:.0}", s.afinar * 100.0).into())],
+ );
+ }
+ }
+ Msg::Escuchar => {
+ if let (Ok(s), Some(p)) = (&model.sesion, &model.player) {
+ if s.mezcla.is_empty() {
+ model.aviso = rimay_localize::t("takiy-karaoke-nothing-sung");
+ } else {
+ p.play(AudioBuffer::from_mono(s.sample_rate, s.mezcla.clone()));
+ model.t = 0.0;
+ model.aviso = rimay_localize::t("takiy-karaoke-listening");
+ }
+ }
+ }
+ Msg::Guardar => {
+ if let Ok(s) = &model.sesion {
+ model.aviso = match s.guardar_mezcla(&model.destino) {
+ Ok(()) => format!("guardado en {}", model.destino.display()),
+ Err(e) => e,
+ };
+ }
+ }
+ Msg::Parar => {
+ if model.rec.is_some() {
+ model = cerrar(model);
+ } else if let Some(p) = &model.player {
+ p.stop();
+ }
+ }
+ }
+ model
+ }
+
+ fn view(model: &Model) -> View<Msg> {
+ let t = &model.theme;
+ let sesion = match &model.sesion {
+ Ok(s) => s,
+ Err(e) => {
+ return columna(t.bg_app)
+ .children(vec![View::new(Style::default()).text(e.clone(), 18.0, t.fg_destructive)])
+ }
+ };
+
+ // El reloj de la letra es el de la **canción**: durante la cuenta
+ // de entrada es negativo, y ahí no hay verso que barrer.
+ let en_cancion = sesion.reloj_cancion(model.t);
+ let cuenta_atras = if sesion.estado == Estado::Cantando && en_cancion < 0.0 {
+ Some((-en_cancion).ceil() as i32)
+ } else {
+ None
+ };
+ let reloj = Duration::from_secs_f32(en_cancion.max(0.0));
+ let mut filas: Vec<View<Msg>> = Vec::new();
+
+ // Cabecera: tema + estado.
+ filas.push(
+ fila(28.0).children(vec![
+ texto_izq(&sesion.titulo, 16.0, t.fg_text),
+ texto_der(
+ match sesion.estado {
+ Estado::Listo => rimay_localize::t("takiy-karaoke-ready"),
+ Estado::Cantando => match cuenta_atras {
+ Some(n) => format!("entra en {n}…"),
+ None => format!("cantando · {:.0}:{:02.0}", en_cancion / 60.0, en_cancion % 60.0),
+ },
+ Estado::Revisando => "toma cerrada — Enter escucha, S guarda".to_string(),
+ },
+ 13.0,
+ t.fg_muted,
+ ),
+ ]),
+ );
+
+ // El cuerpo: el verso anterior, el que se canta y el que viene.
+ let foco = sesion.letra.foco(reloj);
+ let lineas = sesion.letra.lineas();
+ let anterior = foco.and_then(|i| i.checked_sub(1)).and_then(|i| lineas.get(i));
+ let actual = foco.and_then(|i| lineas.get(i));
+ let siguiente = foco.and_then(|i| lineas.get(i + 1));
+
+ filas.push(
+ View::new(Style {
+ flex_direction: FlexDirection::Column,
+ flex_grow: 1.0,
+ gap: Size { width: length(0.0_f32), height: length(18.0_f32) },
+ justify_content: Some(JustifyContent::CENTER),
+ align_items: Some(AlignItems::CENTER),
+ ..Default::default()
+ })
+ .children(vec![
+ verso_apagado(anterior.map(|l| l.texto.as_str()), 20.0, t.fg_placeholder),
+ match cuenta_atras {
+ Some(n) => View::new(Style {
+ size: Size { width: percent(1.0_f32), height: length(60.0_f32) },
+ ..Default::default()
+ })
+ .text(n.to_string(), 48.0, t.accent),
+ None => verso_cantado(actual, reloj, t),
+ },
+ verso_apagado(siguiente.map(|l| l.texto.as_str()), 22.0, t.fg_muted),
+ ]),
+ );
+
+ // La barra del tema.
+ let dur = sesion.duracion_s().max(0.001);
+ filas.push(
+ View::new(Style {
+ size: Size { width: percent(1.0_f32), height: length(8.0_f32) },
+ ..Default::default()
+ })
+ .children(vec![linear_progress_view(en_cancion.max(0.0) / dur, t.bg_panel_alt, t.accent, 8.0)]),
+ );
+
+ // El medidor del micrófono (sólo mientras se canta).
+ if sesion.estado == Estado::Cantando {
+ filas.push(
+ View::new(Style {
+ size: Size { width: percent(1.0_f32), height: length(6.0_f32) },
+ ..Default::default()
+ })
+ .children(vec![linear_progress_view(
+ (model.nivel * 3.0).min(1.0),
+ t.bg_panel_alt,
+ if model.nivel > 0.3 { t.fg_destructive } else { t.accent },
+ 6.0,
+ )]),
+ );
+ }
+
+ // El puntaje de la última toma.
+ if let Some(p) = &sesion.puntaje {
+ if sesion.estado == Estado::Revisando {
+ filas.push(fila(30.0).children(vec![texto_izq(&veredicto(p), 18.0, t.accent)]));
+ }
+ }
+
+ // Pie: qué está pasando y qué se puede tocar.
+ let retardo = if sesion.offset_medido {
+ format!("retardo medido {} ms", sesion.offset_ms)
+ } else if sesion.estado == Estado::Revisando {
+ rimay_localize::t("takiy-karaoke-latency-unmeasured")
+ } else {
+ String::new()
+ };
+ filas.push(fila(22.0).children(vec![
+ texto_izq(&model.aviso, 12.0, t.fg_muted),
+ texto_der(retardo, 12.0, t.fg_muted),
+ ]));
+ filas.push(fila(20.0).children(vec![texto_izq(
+ &rimay_localize::t_args(
+ "takiy-karaoke-keys",
+ &[
+ (
+ "voz",
+ if sesion.quitar_voz {
+ rimay_localize::t("takiy-karaoke-with-voice").into()
+ } else {
+ rimay_localize::t("takiy-karaoke-without-voice").into()
+ },
+ ),
+ ("pct", format!("{:.0}", sesion.afinar * 100.0).into()),
+ ],
+ ),
+ 12.0,
+ t.fg_placeholder,
+ )]));
+
+ columna(t.bg_app).children(filas)
+ }
+
+ fn on_key(_model: &Model, event: &KeyEvent) -> Option<Msg> {
+ if event.state != KeyState::Pressed {
+ return None;
+ }
+ match &event.key {
+ Key::Named(NamedKey::Space) => Some(Msg::Alternar),
+ Key::Named(NamedKey::Enter) => Some(Msg::Escuchar),
+ Key::Named(NamedKey::Escape) => Some(Msg::Parar),
+ Key::Character(s) => match s.to_ascii_lowercase().as_str() {
+ "v" => Some(Msg::QuitarVoz),
+ "a" => Some(Msg::Afinar(0.1)),
+ "z" => Some(Msg::Afinar(-0.1)),
+ "s" => Some(Msg::Guardar),
+ _ => None,
+ },
+ _ => None,
+ }
+ }
+}
+
+// ============================================================
+// Transiciones que tocan hardware
+// ============================================================
+
+/// Arranca la base y abre el micrófono. Los dos en el mismo cuadro: el
+/// desfasaje que quede lo mide después [`cerrar`], no se estima acá.
+fn arrancar(mut model: Model) -> Model {
+ let Ok(s) = &mut model.sesion else { return model };
+ let Some(p) = &model.player else {
+ model.aviso = rimay_localize::t("takiy-karaoke-no-audio-device");
+ return model;
+ };
+ match Recorder::start() {
+ Ok(r) => {
+ p.play(AudioBuffer::from_mono(s.sample_rate, s.pcm_a_tocar()));
+ model.t = 0.0;
+ model.rec = Some(r);
+ s.estado = Estado::Cantando;
+ model.aviso = String::new();
+ }
+ Err(e) => model.aviso = rimay_localize::t_args("takiy-karaoke-no-mic", &[("e", e.to_string().into())]),
+ }
+ model
+}
+
+/// Corta la toma: para la base, cierra el micrófono y le pasa el PCM a la
+/// sesión (que mide, afina, mezcla y puntúa).
+fn cerrar(mut model: Model) -> Model {
+ if let Some(p) = &model.player {
+ p.stop();
+ }
+ let Some(r) = model.rec.take() else { return model };
+ let (voz, sr) = r.finish();
+ if let Ok(s) = &mut model.sesion {
+ s.cerrar_toma(voz, sr);
+ model.aviso = String::new();
+ }
+ model
+}
+
+// ============================================================
+// Piezas de la vista
+// ============================================================
+
+fn columna(fondo: Color) -> View<Msg> {
+ View::new(Style {
+ flex_direction: FlexDirection::Column,
+ size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
+ padding: Rect {
+ left: length(24.0_f32),
+ right: length(24.0_f32),
+ top: length(16.0_f32),
+ bottom: length(16.0_f32),
+ },
+ gap: Size { width: length(0.0_f32), height: length(10.0_f32) },
+ ..Default::default()
+ })
+ .fill(fondo)
+}
+
+fn fila(alto: f32) -> View<Msg> {
+ View::new(Style {
+ flex_direction: FlexDirection::Row,
+ size: Size { width: percent(1.0_f32), height: length(alto) },
+ align_items: Some(AlignItems::CENTER),
+ gap: Size { width: length(12.0_f32), height: length(0.0_f32) },
+ ..Default::default()
+ })
+}
+
+fn texto_izq(s: &str, px: f32, color: Color) -> View<Msg> {
+ View::new(Style { flex_grow: 1.0, ..Default::default() })
+ .text_aligned(s.to_string(), px, color, Alignment::Start)
+}
+
+fn texto_der(s: String, px: f32, color: Color) -> View<Msg> {
+ View::new(Style { flex_shrink: 0.0, ..Default::default() })
+ .text_aligned(s, px, color, Alignment::End)
+}
+
+/// Un verso que no se está cantando (el de antes o el que viene).
+fn verso_apagado(texto: Option<&str>, px: f32, color: Color) -> View<Msg> {
+ View::new(Style { size: Size { width: percent(1.0_f32), height: length(px * 1.6) }, ..Default::default() })
+ .text(texto.unwrap_or("").to_string(), px, color)
+ .ellipsis(1)
+}
+
+/// **El** verso: una sola pasada de shaping con tres colores — lo cantado,
+/// la sílaba de ahora, lo que falta. Es el barrido del karaoke, y por eso
+/// va con `text_runs` y no con tres cajas: partir el texto en tres vistas
+/// rompería el kerning y haría saltar la línea entre sílaba y sílaba.
+fn verso_cantado(linea: Option<&media_core::karaoke::LineaKaraoke>, t: Duration, theme: &Theme) -> View<Msg> {
+ let Some(l) = linea else {
+ return View::new(Style { size: Size { width: percent(1.0_f32), height: length(60.0_f32) }, ..Default::default() })
+ .text("♪".to_string(), 38.0, theme.fg_placeholder);
+ };
+ let (cantado, silaba) = corte(l, t);
+ let mut runs = Vec::new();
+ if cantado > 0 {
+ runs.push((0usize, cantado, theme.fg_muted));
+ }
+ if silaba > cantado {
+ runs.push((cantado, silaba, theme.accent));
+ }
+ View::new(Style { size: Size { width: percent(1.0_f32), height: length(60.0_f32) }, ..Default::default() })
+ .text_runs(l.texto.clone(), 38.0, theme.fg_text, runs, Alignment::Center)
+}
+
+fn main() {
+ llimphi_ui::run::<Karaoke>();
+}
diff --git a/02_ruway/takiy/takiy-karaoke-llimphi/tests/sala.rs b/02_ruway/takiy/takiy-karaoke-llimphi/tests/sala.rs
new file mode 100644
index 0000000..fd53caa
--- /dev/null
+++ b/02_ruway/takiy/takiy-karaoke-llimphi/tests/sala.rs
@@ -0,0 +1,236 @@
+//! Una toma completa, sin ventana y sin hardware: se fabrica un tema
+//! (voz al centro, guitarra a un lado), se escribe el WAV y la letra, se
+//! carga la sesión, se le entrega una toma **atrasada a propósito** y se
+//! exige que la sala mida ese atraso, puntúe y guarde.
+//!
+//! Es la prueba de que las cuatro piezas encajan; lo que queda para el
+//! metal es que el device de audio y el micrófono existan.
+
+use std::f32::consts::PI;
+use std::time::Duration;
+
+use media_core::karaoke::PistaKaraoke;
+use takiy_karaoke_app::{corte, Estado, Sesion};
+
+const SR: u32 = 44_100;
+/// Cuánto se atrasa la toma simulada: el retardo típico de un equipo real.
+const RETARDO_MS: i64 = 40;
+
+fn seno(hz: f32, seg: f32, amp: f32) -> Vec<f32> {
+ let n = (SR as f32 * seg) as usize;
+ (0..n).map(|i| amp * (2.0 * PI * hz * i as f32 / SR as f32).sin()).collect()
+}
+
+/// Una nota con ataque y caída: sin transitorios la envolvente del tema
+/// es una recta, y una recta no se puede alinear con nada (por eso
+/// `detectar_offset` se niega a medir ahí). La música real tiene ataques.
+fn nota(hz: f32, seg: f32, amp: f32) -> Vec<f32> {
+ let n = (SR as f32 * seg) as usize;
+ let ataque = (SR as f32 * 0.005) as usize;
+ (0..n)
+ .map(|i| {
+ let env = if i < ataque {
+ i as f32 / ataque as f32
+ } else {
+ // Cae hasta 0,35 sobre el final: sigue sonando (el YIN
+ // necesita tono) pero el contorno se mueve.
+ let x = (i - ataque) as f32 / (n - ataque).max(1) as f32;
+ 1.0 - 0.65 * x
+ };
+ amp * env * (2.0 * PI * hz * i as f32 / SR as f32).sin()
+ })
+ .collect()
+}
+
+/// La "voz" del tema: cuatro notas de medio segundo — La4, Do5, Re5, Do5.
+fn voz_original() -> Vec<f32> {
+ let mut v = Vec::new();
+ for hz in [440.0, 523.25, 587.33, 523.25] {
+ v.extend(nota(hz, 0.5, 0.5));
+ }
+ v
+}
+
+/// El acompañamiento: ocho pulsaciones de un cuarto de segundo con
+/// acentos **desparejos**. Si todas pegaran igual, el contorno sería
+/// periódico y el retardo tendría ocho respuestas igual de buenas.
+fn acompanamiento(hz: f32) -> Vec<f32> {
+ let acentos = [0.9_f32, 0.35, 0.7, 0.3, 1.0, 0.4, 0.55, 0.25];
+ let mut v = Vec::new();
+ for a in acentos {
+ v.extend(nota(hz, 0.25, 0.25 * a));
+ }
+ v
+}
+
+/// El tema entero en estéreo: la voz al centro, una guitarra a la
+/// izquierda y un pad a la derecha.
+fn tema() -> Vec<f32> {
+ let voz = voz_original();
+ let guit = acompanamiento(196.0);
+ let pad = acompanamiento(293.66);
+ let n = voz.len().min(guit.len()).min(pad.len());
+ let mut inter = Vec::with_capacity(n * 2);
+ for i in 0..n {
+ inter.push(voz[i] + guit[i]);
+ inter.push(voz[i] + pad[i]);
+ }
+ inter
+}
+
+fn escribir_tema(dir: &std::path::Path) -> std::path::PathBuf {
+ let buf = takiy_synth::AudioBuffer { sample_rate: SR, channels: 2, samples: tema() };
+ let p = dir.join("tema.wav");
+ takiy_synth::write_wav(&buf, &p).unwrap();
+ p
+}
+
+fn escribir_letra(dir: &std::path::Path) -> std::path::PathBuf {
+ let p = dir.join("tema.lrc");
+ std::fs::write(
+ &p,
+ "[ti:Prueba de sala]\n[ar:nadie]\n\
+ [00:00.00]<00:00.00>La <00:00.50>si <00:01.00>re <00:01.50>si\n\
+ [00:02.00]\n",
+ )
+ .unwrap();
+ p
+}
+
+fn dir_temporal(nombre: &str) -> std::path::PathBuf {
+ let d = std::env::temp_dir().join(format!("takiy-karaoke-{nombre}"));
+ let _ = std::fs::remove_dir_all(&d);
+ std::fs::create_dir_all(&d).unwrap();
+ d
+}
+
+#[test]
+fn una_toma_de_punta_a_punta() {
+ let dir = dir_temporal("toma");
+ let wav = escribir_tema(&dir);
+ let lrc = escribir_letra(&dir);
+
+ let mut s = Sesion::cargar(&wav, Some(&lrc)).expect("la sesión tiene que cargar");
+ assert_eq!(s.estado, Estado::Listo);
+ assert!(s.tiene_estereo(), "el tema es estéreo");
+ assert!((s.duracion_s() - 2.0).abs() < 0.05, "dura {}", s.duracion_s());
+
+ // La letra llegó con sus sílabas y el título salió del LRC.
+ assert_eq!(s.letra.titulo.as_deref(), Some("Prueba de sala"));
+ assert_eq!(s.letra.silabas(), 4);
+
+ // La melodía de referencia se dedujo del centro del tema: tiene que
+ // reconocer la primera nota (La4 = MIDI 69).
+ assert!(!s.melodia.is_empty(), "sin melodía no hay puntaje");
+ assert_eq!(s.melodia[0].midi, 69, "la primera nota del tema es La4");
+
+ // La base sin voz existe y NO es el tema: se le fue el centro.
+ s.quitar_voz = true;
+ let sin = s.base_a_tocar().to_vec();
+ s.quitar_voz = false;
+ let con = s.base_a_tocar().to_vec();
+ assert_eq!(sin.len(), con.len());
+ let energia = |x: &[f32]| x.iter().map(|v| v * v).sum::<f32>() / x.len() as f32;
+ assert!(energia(&sin) < energia(&con) * 0.6, "sin voz tiene que tener menos energía");
+
+ // La toma: el micrófono capta lo que sale del parlante (cuenta de
+ // entrada incluida) y encima, desde que termina la cuenta, a alguien
+ // cantando. Todo llega 40 ms tarde — eso es lo que hay que medir.
+ s.quitar_voz = true;
+ let sonando = s.pcm_a_tocar();
+ let cuenta_n = s.cuenta_muestras();
+ let retardo_n = (SR as i64 * RETARDO_MS / 1000) as usize;
+ let mut toma = vec![0.0f32; retardo_n];
+ let cantada = voz_original();
+ for i in 0..sonando.len() {
+ let filtracion = sonando[i] * 0.35;
+ let voz = if i >= cuenta_n { cantada.get(i - cuenta_n).copied().unwrap_or(0.0) } else { 0.0 };
+ toma.push(voz * 0.8 + filtracion);
+ }
+
+ s.cerrar_toma(toma, SR);
+
+ assert_eq!(s.estado, Estado::Revisando);
+ assert!(s.offset_medido, "el retardo tiene que medirse, no suponerse");
+ assert!(
+ (s.offset_ms - RETARDO_MS).abs() <= 5,
+ "midió {} ms, era {RETARDO_MS}",
+ s.offset_ms
+ );
+
+ let p = s.puntaje.as_ref().expect("tiene que haber puntaje");
+ assert!(p.total > 70.0, "cantó la melodía del tema: {}", p.total);
+ assert!(p.cobertura > 0.5, "cobertura {}", p.cobertura);
+
+ // La mezcla existe, dura lo que la canción y NO trae la cuenta.
+ assert!(!s.mezcla.is_empty());
+ let cancion_n = sonando.len() - cuenta_n;
+ // Puede sobrar la cola de la voz por el error de medición (ms), nunca
+ // faltar canción.
+ assert!(s.mezcla.len() >= cancion_n, "{} < {cancion_n}", s.mezcla.len());
+ assert!(s.mezcla.len() - cancion_n < SR as usize / 100, "sobra demasiada cola");
+
+ // Y se guarda: el WAV vuelve a leerse con el mismo largo.
+ let destino = dir.join("toma.wav");
+ s.guardar_mezcla(&destino).unwrap();
+ let releido = takiy_tanda::cosecha::leer_wav_de(std::fs::File::open(&destino).unwrap()).unwrap();
+ assert_eq!(releido.sample_rate, SR);
+ assert_eq!(releido.samples.len(), s.mezcla.len());
+}
+
+#[test]
+fn desafinar_baja_el_puntaje() {
+ let dir = dir_temporal("desafina");
+ let wav = escribir_tema(&dir);
+ let mut afinado = Sesion::cargar(&wav, None).unwrap();
+ let mut chillon = Sesion::cargar(&wav, None).unwrap();
+
+ // Sin cuenta captada, el retardo no se puede medir: la toma arranca
+ // donde arranca la canción y se puntúa con offset cero.
+ let mudo = |sesion: &Sesion| vec![0.0f32; sesion.cuenta_muestras()];
+ let mut toma_bien = mudo(&afinado);
+ toma_bien.extend(voz_original());
+ afinado.cerrar_toma(toma_bien, SR);
+ // El mismo canto, medio tono arriba: 2^(0.5/12) sobre cada frecuencia.
+ let mut mal = Vec::new();
+ for hz in [440.0_f32, 523.25, 587.33, 523.25] {
+ mal.extend(nota(hz * 2.0_f32.powf(0.5 / 12.0), 0.5, 0.5));
+ }
+ let mut toma_mal = mudo(&chillon);
+ toma_mal.extend(mal);
+ chillon.cerrar_toma(toma_mal, SR);
+
+ let bien = afinado.puntaje.as_ref().unwrap().total;
+ let feo = chillon.puntaje.as_ref().unwrap().total;
+ assert!(bien > 80.0, "afinado: {bien}");
+ assert!(feo < bien - 20.0, "medio tono arriba tiene que pagar: {feo} vs {bien}");
+}
+
+#[test]
+fn sin_letra_la_sala_igual_funciona() {
+ let dir = dir_temporal("sinletra");
+ let wav = escribir_tema(&dir);
+ let s = Sesion::cargar(&wav, None).unwrap();
+ assert!(s.letra.is_empty());
+ assert!(!s.melodia.is_empty(), "la melodía sale del tema, no de la letra");
+ assert_eq!(s.letra.foco(Duration::from_secs(1)), None);
+}
+
+#[test]
+fn el_barrido_avanza_con_el_reloj() {
+ let dir = dir_temporal("barrido");
+ let lrc = escribir_letra(&dir);
+ let texto = std::fs::read_to_string(&lrc).unwrap();
+ let letra = PistaKaraoke::parse(&texto).unwrap();
+ let l = letra.en(Duration::from_millis(100)).expect("hay línea activa a los 100 ms");
+
+ // El corte sólo puede avanzar con el tiempo, nunca retroceder.
+ let mut previo = 0usize;
+ for t in (0..2000).step_by(50) {
+ let (cantado, _) = corte(l, Duration::from_millis(t));
+ assert!(cantado >= previo, "el barrido retrocedió en {t} ms");
+ previo = cantado;
+ }
+ // Y al final de la línea está todo cantado.
+ assert_eq!(corte(l, Duration::from_millis(1999)).0 + "si".len(), l.texto.len());
+}
diff --git a/02_ruway/takiy/takiy-karaoke/Cargo.toml b/02_ruway/takiy/takiy-karaoke/Cargo.toml
new file mode 100644
index 0000000..af1237c
--- /dev/null
+++ b/02_ruway/takiy/takiy-karaoke/Cargo.toml
@@ -0,0 +1,9 @@
+[package]
+name = "takiy-karaoke"
+version = "0.1.0"
+edition = "2021"
+description = "Cantar encima de una pista: base sin voz, latencia medida, mezcla de la toma y puntaje de afinación."
+
+[dependencies]
+takiy-core = { path = "../takiy-core" }
+takiy-escucha = { path = "../takiy-escucha" }
diff --git a/02_ruway/takiy/takiy-karaoke/LEEME.md b/02_ruway/takiy/takiy-karaoke/LEEME.md
new file mode 100644
index 0000000..69004ff
--- /dev/null
+++ b/02_ruway/takiy/takiy-karaoke/LEEME.md
@@ -0,0 +1,19 @@
+# takiy-karaoke
+
+*Read this in English: [README.md](README.md).*
+
+> Lo que hay que resolver para **cantar encima** de una pista que ya existe, y que ni el
+> reproductor ni el secuenciador tienen por separado.
+
+El reproductor (`media`) sabe traer el tema y la letra; el secuenciador (`takiy`) sabe grabar
+la voz, afinarla y editarla. Entre los dos quedan cuatro huecos, y este crate es esos cuatro:
+
+- la **base sin voz**;
+- la **latencia medida** —no supuesta, porque un offset adivinado deja todas las tomas fuera
+ de tiempo—;
+- la **mezcla** de la toma sobre la base;
+- el **puntaje de afinación** al final.
+
+---
+
+Parte de **takiy** — ver [takiy](../LEEME.md).
diff --git a/02_ruway/takiy/takiy-karaoke/README.md b/02_ruway/takiy/takiy-karaoke/README.md
new file mode 100644
index 0000000..8ccd35f
--- /dev/null
+++ b/02_ruway/takiy/takiy-karaoke/README.md
@@ -0,0 +1,18 @@
+# takiy-karaoke
+
+> What has to be solved in order to **sing over** a track that already exists — and that
+> neither the player nor the sequencer has on its own.
+
+The player (`media`) knows how to bring the song and the lyrics; the sequencer (`takiy`)
+knows how to record the voice, tune it and edit it. Four gaps sit between them, and this
+crate is those four:
+
+- the **backing track without the vocal**;
+- the **latency, measured** — not assumed, because a guessed offset puts every take out of
+ time;
+- the **mix** of the take over the backing;
+- the **pitch score** at the end.
+
+---
+
+Part of **takiy** — see [takiy](../README.md).
diff --git a/02_ruway/takiy/takiy-karaoke/src/lib.rs b/02_ruway/takiy/takiy-karaoke/src/lib.rs
new file mode 100644
index 0000000..43c4e0d
--- /dev/null
+++ b/02_ruway/takiy/takiy-karaoke/src/lib.rs
@@ -0,0 +1,636 @@
+//! `takiy-karaoke` — lo que hay que resolver para **cantar encima** de una
+//! pista que ya existe, y que ni el reproductor ni el secuenciador tienen
+//! por separado.
+//!
+//! El reproductor (`media`) sabe traer el tema y la letra; el secuenciador
+//! (`takiy`) sabe grabar la voz, afinarla y editarla. Entre los dos quedan
+//! cuatro huecos, y son estos:
+//!
+//! 1. [`sin_voz`] — sacarle la voz a una mezcla estéreo para tener base.
+//! 2. [`detectar_offset`] — **cuánto llegó tarde** la toma. El micrófono
+//! graba lo que el parlante ya emitió: sin medir ese retardo la voz
+//! queda corrida contra la base y la mezcla suena mal aunque el que
+//! cantó haya estado perfecto. Se mide, no se estima.
+//! 3. [`mezclar`] — juntar base y voz con ese retardo compensado.
+//! 4. [`puntuar`] — comparar el tono cantado contra la melodía esperada.
+//!
+//! Todo es núcleo puro: entra y sale PCM mono `f32`, sin reloj, sin I/O y
+//! sin audio device. Quien abre el micrófono es `takiy_playback::Recorder`;
+//! quien afina la toma es `takiy_synth::autotune`; quien pinta la letra
+//! barrida es `media_core::karaoke`.
+
+#![forbid(unsafe_code)]
+
+use takiy_core::{AudioClip, ScoreNote};
+use takiy_escucha::dsp::{rms, yin_pitch};
+use takiy_escucha::pitch_track::tararear_a_notas;
+
+// ============================================================
+// 1 — La base: quitarle la voz a una mezcla
+// ============================================================
+
+/// Saca el **centro** de una mezcla estéreo interleaved (`[L0,R0,L1,R1,…]`)
+/// y devuelve mono: la vieja cancelación de fase `L−R`, que es lo que hacen
+/// los equipos de karaoke.
+///
+/// Honestidad sobre lo que hace y lo que no: se lleva puesto **todo** lo
+/// que esté paneado al centro, no sólo la voz — bombo, bajo y caja suelen
+/// estarlo. Por eso `bajos_hz` re-inyecta la parte grave del centro (un
+/// pasabajos de un polo sobre `(L+R)/2`): con `120.0` vuelve el fundamento
+/// rítmico y la voz sigue afuera. `0.0` = cancelación pura.
+///
+/// No sirve si la voz no está centrada, si la mezcla es mono, o si el tema
+/// trae reverb ancha sobre la voz (queda el fantasma). Para eso hace falta
+/// separación de fuentes, que es otro animal.
+pub fn sin_voz(estereo: &[f32], sample_rate: u32, bajos_hz: f32) -> Vec<f32> {
+ let frames = estereo.len() / 2;
+ let mut out = Vec::with_capacity(frames);
+ // Pasabajos de un polo: y[n] = y[n-1] + a·(x[n] − y[n-1]).
+ let a = if bajos_hz > 0.0 && sample_rate > 0 {
+ let rc = 1.0 / (2.0 * std::f32::consts::PI * bajos_hz);
+ let dt = 1.0 / sample_rate as f32;
+ (dt / (rc + dt)).clamp(0.0, 1.0)
+ } else {
+ 0.0
+ };
+ let mut y = 0.0f32;
+ for i in 0..frames {
+ let l = estereo[2 * i];
+ let r = estereo[2 * i + 1];
+ let lado = (l - r) * 0.5;
+ let medio = (l + r) * 0.5;
+ y += a * (medio - y);
+ out.push((lado + if a > 0.0 { y } else { 0.0 }).clamp(-1.0, 1.0));
+ }
+ out
+}
+
+/// El **centro** de una mezcla estéreo, que es donde vive la voz: la
+/// contracara de [`sin_voz`]. `agudos_hz` es un pasaaltos de un polo que
+/// saca el bombo y el bajo (también centrados) para dejar la línea
+/// cantada lo más sola posible.
+///
+/// No separa fuentes: lo que devuelve es «el centro», voz **más** lo que
+/// comparta el centro por encima de ese corte. Alcanza para seguirle el
+/// tono a la melodía, que es para lo que está — ver [`melodia_de_audio`].
+pub fn centro(estereo: &[f32], sample_rate: u32, agudos_hz: f32) -> Vec<f32> {
+ let frames = estereo.len() / 2;
+ let a = if agudos_hz > 0.0 && sample_rate > 0 {
+ let rc = 1.0 / (2.0 * std::f32::consts::PI * agudos_hz);
+ let dt = 1.0 / sample_rate as f32;
+ (dt / (rc + dt)).clamp(0.0, 1.0)
+ } else {
+ 0.0
+ };
+ let mut y = 0.0f32;
+ let mut out = Vec::with_capacity(frames);
+ for i in 0..frames {
+ let medio = (estereo[2 * i] + estereo[2 * i + 1]) * 0.5;
+ if a > 0.0 {
+ y += a * (medio - y);
+ out.push(medio - y); // pasaaltos = señal − pasabajos
+ } else {
+ out.push(medio);
+ }
+ }
+ out
+}
+
+// ============================================================
+// 2 — La latencia: cuánto llegó tarde la toma
+// ============================================================
+
+/// Ancho del propio pico de correlación, en ms: dentro de esta vecindad
+/// del máximo no se busca competencia (es el mismo pico, no otro).
+const VECINDAD_MS: u32 = 25;
+/// Muestras por cuadro de envolvente al medir el retardo. 64 @ 48 kHz =
+/// 1,33 ms de resolución: por debajo de lo que un oído nota en una mezcla.
+const HOP_ENV: usize = 64;
+
+/// Envolvente de energía por cuadro (RMS), normalizada a pico 1.
+fn envolvente(x: &[f32], hop: usize) -> Vec<f32> {
+ let mut env: Vec<f32> = x.chunks(hop).map(rms).collect();
+ let pico = env.iter().cloned().fold(0.0f32, f32::max);
+ if pico > 0.0 {
+ for v in &mut env {
+ *v /= pico;
+ }
+ }
+ env
+}
+
+/// Mide en **milisegundos** cuánto se atrasó `grabado` respecto de
+/// `referencia`: el retardo de ida y vuelta de la máquina (buffer de
+/// salida + aire + buffer de entrada).
+///
+/// Cómo: correlación cruzada de las envolventes de energía, no de las
+/// muestras. Lo que el micrófono capta de la base está filtrado por el
+/// parlante y la sala —su forma de onda no se parece a la original— pero
+/// **la energía sí sigue el mismo contorno**, y el contorno es lo que
+/// alinea.
+///
+/// Devuelve el desplazamiento que hay que aplicarle a la toma para que
+/// caiga sobre la base: positivo = la toma llegó tarde y hay que
+/// **adelantarla** ese tanto. `None` si no hay correlación creíble
+/// (silencio, o un micrófono que no escucha nada de la base — auriculares
+/// cerrados: ahí el retardo se calibra con un click, no con el tema).
+pub fn detectar_offset(
+ grabado: &[f32],
+ referencia: &[f32],
+ sample_rate: u32,
+ max_ms: u32,
+) -> Option<i64> {
+ if sample_rate == 0 || grabado.is_empty() || referencia.is_empty() {
+ return None;
+ }
+ let env_g = envolvente(grabado, HOP_ENV);
+ let env_r = envolvente(referencia, HOP_ENV);
+ if env_g.len() < 8 || env_r.len() < 8 {
+ return None;
+ }
+ let max_cuadros = ((max_ms as f32 / 1000.0) * sample_rate as f32 / HOP_ENV as f32) as i64;
+ let max_cuadros = max_cuadros.max(1);
+
+ // Correlación por desplazamiento; se guardan todas para poder
+ // preguntarle a la curva si el pico es **único** (ver abajo).
+ let mut curva: Vec<(i64, f32)> = Vec::new();
+ for d in -max_cuadros..=max_cuadros {
+ // `d` = cuánto se atrasó el grabado: comparamos g[i] con r[i−d].
+ // Correlación de Pearson (con la media quitada), no coseno: dos
+ // envolventes **planas** —un tono sostenido, un zumbido— dan
+ // coseno 1 en cualquier desplazamiento, y eso sería inventar un
+ // retardo. Sin varianza no hay medición, y hay que decirlo.
+ let mut pares: Vec<(f32, f32)> = Vec::with_capacity(env_g.len());
+ for i in 0..env_g.len() {
+ let j = i as i64 - d;
+ if j < 0 || j as usize >= env_r.len() {
+ continue;
+ }
+ pares.push((env_g[i], env_r[j as usize]));
+ }
+ if pares.len() < env_g.len() / 4 || pares.len() < 8 {
+ continue;
+ }
+ let n = pares.len() as f32;
+ let media_a = pares.iter().map(|(a, _)| *a).sum::<f32>() / n;
+ let media_b = pares.iter().map(|(_, b)| *b).sum::<f32>() / n;
+ let mut num = 0.0f32;
+ let mut va = 0.0f32;
+ let mut vb = 0.0f32;
+ for (a, b) in &pares {
+ let da = a - media_a;
+ let db = b - media_b;
+ num += da * db;
+ va += da * da;
+ vb += db * db;
+ }
+ // Umbral de varianza: por debajo de esto la envolvente es una
+ // línea recta y su "mejor" desplazamiento es ruido numérico.
+ if va / n < 1e-6 || vb / n < 1e-6 {
+ continue;
+ }
+ curva.push((d, num / (va.sqrt() * vb.sqrt())));
+ }
+ let Some(&(mejor_d, mejor_c)) = curva
+ .iter()
+ .max_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal))
+ else {
+ return None;
+ };
+ // Por debajo de esto es ruido correlacionando con ruido.
+ if mejor_c < 0.5 {
+ return None;
+ }
+ // El pico tiene que ser **único**: si hay otro casi igual de bueno
+ // lejos del elegido, la señal es periódica (un zumbido, un loop) y
+ // cualquiera de los dos "encaja". Ahí no hay medición — hay dos
+ // respuestas, y elegir una es inventarla.
+ //
+ // «Lejos» es más que el ancho del propio pico: un click dura decenas
+ // de milisegundos, así que un desplazamiento de dos o tres cuadros
+ // sigue apoyado sobre el **mismo** click y correlaciona casi igual.
+ // Eso no es un rival, es el mismo pico visto de costado.
+ let vecindad = ((VECINDAD_MS as f32 / 1000.0) * sample_rate as f32 / HOP_ENV as f32) as i64;
+ let rival = curva
+ .iter()
+ .filter(|(d, _)| (d - mejor_d).abs() > vecindad.max(1))
+ .map(|(_, c)| *c)
+ .fold(f32::MIN, f32::max);
+ if rival > mejor_c * 0.9 {
+ return None;
+ }
+ let ms = mejor_d as f64 * HOP_ENV as f64 * 1000.0 / sample_rate as f64;
+ Some(ms.round() as i64)
+}
+
+// ============================================================
+// 3 — La mezcla
+// ============================================================
+
+/// Junta base y voz en un mono, compensando el retardo medido.
+///
+/// `offset_ms` es lo que devolvió [`detectar_offset`]: positivo adelanta la
+/// toma (le corta ese arranque, que es aire). Negativo la atrasa con
+/// silencio. El resultado dura lo que dure el más largo de los dos, y se
+/// acota a `[-1, 1]` (sin limitador: si satura, bajá `gan_voz`).
+pub fn mezclar(
+ base: &[f32],
+ voz: &[f32],
+ sample_rate: u32,
+ offset_ms: i64,
+ gan_base: f32,
+ gan_voz: f32,
+) -> Vec<f32> {
+ let corr = (offset_ms as f64 * sample_rate as f64 / 1000.0).round() as i64;
+ // Posición de la muestra 0 de la voz dentro de la salida.
+ let inicio_voz = -corr;
+ let fin_voz = inicio_voz + voz.len() as i64;
+ let n = base.len().max(fin_voz.max(0) as usize);
+ let mut out = vec![0.0f32; n];
+ for (i, b) in base.iter().enumerate() {
+ out[i] = b * gan_base;
+ }
+ for (k, v) in voz.iter().enumerate() {
+ let i = inicio_voz + k as i64;
+ if i < 0 || i as usize >= n {
+ continue;
+ }
+ out[i as usize] = (out[i as usize] + v * gan_voz).clamp(-1.0, 1.0);
+ }
+ out
+}
+
+// ============================================================
+// 4 — El puntaje
+// ============================================================
+
+/// Una nota que la melodía **espera** en un tramo de tiempo.
+#[derive(Debug, Clone, Copy, PartialEq)]
+pub struct NotaEsperada {
+ /// Número MIDI de la nota (60 = do central).
+ pub midi: u8,
+ /// Inicio en segundos desde el arranque de la toma.
+ pub inicio_s: f32,
+ /// Fin en segundos.
+ pub fin_s: f32,
+}
+
+impl NotaEsperada {
+ pub fn new(midi: u8, inicio_s: f32, fin_s: f32) -> Self {
+ NotaEsperada { midi, inicio_s, fin_s }
+ }
+}
+
+/// Traduce la melodía de una pista de `takiy` (pulsos) a segundos.
+pub fn melodia_de_notas(notas: &[ScoreNote], bpm: f32) -> Vec<NotaEsperada> {
+ let seg_por_pulso = if bpm > 0.0 { 60.0 / bpm } else { 0.5 };
+ notas
+ .iter()
+ .map(|n| {
+ NotaEsperada::new(
+ n.pitch.midi(),
+ n.start * seg_por_pulso,
+ (n.start + n.duration) * seg_por_pulso,
+ )
+ })
+ .collect()
+}
+
+/// Saca la melodía esperada **del tema mismo**: le sigue el tono a la
+/// pista (típicamente el [`centro`] de la mezcla, donde está la voz
+/// original) y la transcribe a notas.
+///
+/// Es lo que permite puntuar sin partitura: la referencia es lo que
+/// cantó quien grabó el tema. Hereda todos los límites de la
+/// transcripción monofónica — con dos voces o un solo instrumental
+/// encima, la melodía que sale no es la que se espera cantar.
+pub fn melodia_de_audio(pcm: &[f32], sample_rate: u32) -> Vec<NotaEsperada> {
+ // 120 bpm es una unidad arbitraria: `tararear_a_notas` devuelve pulsos
+ // y acá volvemos a segundos con el mismo tempo, así que se cancela.
+ const BPM: f32 = 120.0;
+ let clip = AudioClip::new(pcm.to_vec(), sample_rate, 0.0);
+ let notas = tararear_a_notas(&clip, BPM);
+ melodia_de_notas(¬as, BPM)
+}
+
+/// Cómo le fue a una nota.
+#[derive(Debug, Clone, Copy, PartialEq)]
+pub struct NotaCantada {
+ /// Fracción `0..=1` del tramo en que hubo voz con tono claro. Cero =
+ /// no cantó ahí (y el puntaje de la nota es cero).
+ pub cobertura: f32,
+ /// Desvío en **cents** contra la nota esperada, ignorando la octava
+ /// (cantar una octava abajo no es desafinar). `None` si no hubo voz.
+ pub cents: Option<f32>,
+ /// Puntaje `0..=100` de esta nota.
+ pub puntos: f32,
+}
+
+/// El resultado de una toma.
+#[derive(Debug, Clone, PartialEq)]
+pub struct Puntaje {
+ pub por_nota: Vec<NotaCantada>,
+ /// Promedio `0..=100`.
+ pub total: f32,
+ /// Desvío **con signo** promedio en cents: negativo = cantó bajo.
+ /// Es el dato que dice «afinás bien pero medio abajo».
+ pub desvio_cents: f32,
+ /// Fracción de las notas en que efectivamente hubo voz.
+ pub cobertura: f32,
+}
+
+/// Tolerancia: a partir de este desvío la nota vale 0.
+const CENTS_CERO: f32 = 200.0;
+/// Ventana de análisis (a 44,1 kHz ≈ 46 ms) y salto entre ventanas.
+const WIN: usize = 2048;
+const HOP: usize = 512;
+/// Claridad YIN mínima para creerle a un cuadro.
+const CLARIDAD_MIN: f32 = 0.55;
+
+/// Compara la toma contra la melodía esperada y puntúa nota por nota.
+///
+/// `offset_s` corre la melodía respecto de la toma (el mismo retardo de
+/// [`detectar_offset`], en segundos, con el mismo signo).
+///
+/// El criterio, explícito: por cada nota se toma la **mediana** del tono
+/// detectado en su tramo (la mediana ignora los saltos de octava y los
+/// cuadros sueltos que el YIN erra); el error se mide módulo 12 semitonos
+/// —octava libre— y se convierte a puntos linealmente hasta
+/// [`CENTS_CERO`]; y todo eso se multiplica por la cobertura, así callarse
+/// no puntúa.
+pub fn puntuar(
+ voz: &[f32],
+ sample_rate: u32,
+ esperado: &[NotaEsperada],
+ offset_s: f32,
+) -> Puntaje {
+ let mut por_nota = Vec::with_capacity(esperado.len());
+ let mut suma = 0.0f32;
+ let mut suma_cents = 0.0f32;
+ let mut con_voz = 0usize;
+
+ for nota in esperado {
+ let ini = (((nota.inicio_s + offset_s) * sample_rate as f32) as isize).max(0) as usize;
+ let fin = (((nota.fin_s + offset_s) * sample_rate as f32) as isize).max(0) as usize;
+ let fin = fin.min(voz.len());
+ if ini >= fin || fin - ini < WIN {
+ por_nota.push(NotaCantada { cobertura: 0.0, cents: None, puntos: 0.0 });
+ continue;
+ }
+ let tramo = &voz[ini..fin];
+ let mut midis: Vec<f32> = Vec::new();
+ let mut cuadros = 0usize;
+ let mut i = 0;
+ while i + WIN <= tramo.len() {
+ cuadros += 1;
+ if let Some((f, claridad)) = yin_pitch(&tramo[i..i + WIN], sample_rate, 70.0, 1200.0, 0.15)
+ {
+ if claridad >= CLARIDAD_MIN && f > 0.0 {
+ midis.push(69.0 + 12.0 * (f / 440.0).log2());
+ }
+ }
+ i += HOP;
+ }
+ let cobertura = if cuadros == 0 { 0.0 } else { midis.len() as f32 / cuadros as f32 };
+ if midis.is_empty() {
+ por_nota.push(NotaCantada { cobertura: 0.0, cents: None, puntos: 0.0 });
+ continue;
+ }
+ midis.sort_by(|a, b| a.partial_cmp(b).unwrap());
+ let mediana = midis[midis.len() / 2];
+ // Error módulo octava, con signo, en el rango [-6, 6) semitonos.
+ let mut err = mediana - nota.midi as f32;
+ err -= 12.0 * (err / 12.0).round();
+ let cents = err * 100.0;
+ let puntos = (1.0 - (cents.abs() / CENTS_CERO).min(1.0)) * 100.0 * cobertura.min(1.0);
+ suma += puntos;
+ suma_cents += cents;
+ con_voz += 1;
+ por_nota.push(NotaCantada { cobertura, cents: Some(cents), puntos });
+ }
+
+ let n = esperado.len().max(1) as f32;
+ Puntaje {
+ total: suma / n,
+ desvio_cents: if con_voz == 0 { 0.0 } else { suma_cents / con_voz as f32 },
+ cobertura: con_voz as f32 / n,
+ por_nota,
+ }
+}
+
+// ============================================================
+// Tests
+// ============================================================
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ const SR: u32 = 44_100;
+
+ /// Seno mono de `hz` durante `seg`.
+ fn seno(hz: f32, seg: f32, amp: f32) -> Vec<f32> {
+ let n = (SR as f32 * seg) as usize;
+ (0..n)
+ .map(|i| amp * (2.0 * std::f32::consts::PI * hz * i as f32 / SR as f32).sin())
+ .collect()
+ }
+
+ /// Estéreo interleaved: `centro` al medio, `izq`/`der` a los lados.
+ fn estereo(centro: &[f32], izq: &[f32], der: &[f32]) -> Vec<f32> {
+ let n = centro.len().min(izq.len()).min(der.len());
+ let mut v = Vec::with_capacity(n * 2);
+ for i in 0..n {
+ v.push(centro[i] + izq[i]);
+ v.push(centro[i] + der[i]);
+ }
+ v
+ }
+
+ fn pico(x: &[f32]) -> f32 {
+ x.iter().fold(0.0f32, |a, b| a.max(b.abs()))
+ }
+
+ #[test]
+ fn sin_voz_cancela_el_centro_y_deja_los_lados() {
+ let voz = seno(440.0, 0.5, 0.5); // centrada
+ let guit = seno(330.0, 0.5, 0.3); // sólo izquierda
+ let cero = vec![0.0; guit.len()];
+ let mezcla = estereo(&voz, &guit, &cero);
+ let base = sin_voz(&mezcla, SR, 0.0);
+ // La voz centrada desaparece; la guitarra lateral sobrevive.
+ assert!(pico(&base) > 0.10, "la guitarra tiene que quedar: {}", pico(&base));
+ // Contra la mezcla original, el pico bajó porque se fue la voz.
+ assert!(pico(&base) < 0.25);
+ // Con la voz sola en el centro, cancelación total.
+ let solo_voz = estereo(&voz, &cero, &cero);
+ assert!(pico(&sin_voz(&solo_voz, SR, 0.0)) < 1e-6);
+ }
+
+ #[test]
+ fn sin_voz_reinyecta_los_graves_del_centro() {
+ let bombo = seno(60.0, 0.5, 0.6); // centrado y grave
+ let voz = seno(440.0, 0.5, 0.5); // centrada y media
+ let centro: Vec<f32> = bombo.iter().zip(&voz).map(|(a, b)| a + b).collect();
+ let cero = vec![0.0; centro.len()];
+ let mezcla = estereo(¢ro, &cero, &cero);
+ let puro = sin_voz(&mezcla, SR, 0.0);
+ let con_graves = sin_voz(&mezcla, SR, 120.0);
+ assert!(pico(&puro) < 1e-6, "cancelación pura se lleva todo el centro");
+ assert!(pico(&con_graves) > 0.2, "el bombo tiene que volver: {}", pico(&con_graves));
+ }
+
+ #[test]
+ fn detectar_offset_mide_el_retardo_real() {
+ // Base con pulsos claros, y una "toma" que es esa base atenuada,
+ // filtrada (como la oiría un micrófono) y llegando 40 ms tarde.
+ let mut base = vec![0.0f32; SR as usize * 2];
+ for k in 0..8 {
+ let ini = k * SR as usize / 4;
+ for i in 0..(SR as usize / 20) {
+ let t = i as f32 / SR as f32;
+ base[ini + i] = (2.0 * std::f32::consts::PI * 220.0 * t).sin() * (1.0 - i as f32 / (SR as f32 / 20.0));
+ }
+ }
+ let retardo = (SR as f32 * 0.040) as usize; // 40 ms
+ let mut toma = vec![0.0f32; retardo];
+ toma.extend(base.iter().map(|s| s * 0.3));
+ let medido = detectar_offset(&toma, &base, SR, 200).expect("tiene que correlacionar");
+ assert!((medido - 40).abs() <= 3, "medido {medido} ms, esperado 40");
+ }
+
+ #[test]
+ fn detectar_offset_no_inventa_con_silencio() {
+ let base = seno(220.0, 1.0, 0.5);
+ let silencio = vec![0.0f32; SR as usize];
+ assert_eq!(detectar_offset(&silencio, &base, SR, 200), None);
+ }
+
+ #[test]
+ fn detectar_offset_no_mide_sobre_un_tono_plano() {
+ // Dos zumbidos continuos: la envolvente es una recta y cualquier
+ // desplazamiento "encaja". Medir ahí sería inventar un número.
+ let a = seno(220.0, 2.0, 0.5);
+ let b = seno(220.0, 2.0, 0.2);
+ assert_eq!(detectar_offset(&a, &b, SR, 200), None);
+ }
+
+ #[test]
+ fn mezclar_compensa_el_retardo() {
+ let base: Vec<f32> = (0..1000).map(|i| i as f32 / 1000.0).collect();
+ // La voz es un impulso en su muestra 441 (10 ms) y llegó 10 ms tarde:
+ // compensada, el impulso tiene que caer en la muestra 0 de la salida.
+ let mut voz = vec![0.0f32; 1000];
+ voz[441] = 1.0;
+ let out = mezclar(&base, &voz, SR, 10, 0.0, 1.0);
+ assert!((out[0] - 1.0).abs() < 1e-6, "el impulso debe caer en 0");
+ assert!(out[441].abs() < 1e-6);
+ // Sin compensar, sigue en 441.
+ let out0 = mezclar(&base, &voz, SR, 0, 0.0, 1.0);
+ assert!((out0[441] - 1.0).abs() < 1e-6);
+ // Y las ganancias hacen lo que dicen.
+ let solo_base = mezclar(&base, &voz, SR, 0, 0.5, 0.0);
+ assert!((solo_base[100] - base[100] * 0.5).abs() < 1e-6);
+ }
+
+ #[test]
+ fn mezclar_estira_la_salida_si_la_voz_sobra() {
+ let base = vec![0.1f32; 100];
+ let voz = vec![0.2f32; 500];
+ let out = mezclar(&base, &voz, SR, 0, 1.0, 1.0);
+ assert_eq!(out.len(), 500);
+ assert!((out[0] - 0.3).abs() < 1e-6);
+ assert!((out[300] - 0.2).abs() < 1e-6);
+ }
+
+ #[test]
+ fn puntuar_premia_la_nota_justa_y_castiga_la_desafinada() {
+ // La4 = 440 Hz = MIDI 69, un segundo.
+ let afinado = seno(440.0, 1.0, 0.5);
+ let p = puntuar(&afinado, SR, &[NotaEsperada::new(69, 0.0, 1.0)], 0.0);
+ assert!(p.total > 95.0, "afinado debería puntuar alto: {}", p.total);
+ assert!(p.por_nota[0].cents.unwrap().abs() < 10.0);
+ assert!(p.cobertura > 0.9);
+
+ // Medio semitono arriba (466 Hz ≈ 100 cents) → mitad de puntaje.
+ let alto = seno(466.16, 1.0, 0.5);
+ let p2 = puntuar(&alto, SR, &[NotaEsperada::new(69, 0.0, 1.0)], 0.0);
+ assert!(p2.total > 30.0 && p2.total < 70.0, "medio tono arriba: {}", p2.total);
+ assert!(p2.desvio_cents > 50.0, "y el desvío tiene que salir positivo");
+ }
+
+ #[test]
+ fn puntuar_no_castiga_cantar_una_octava_abajo() {
+ // 220 Hz contra una melodía escrita en 440: misma nota, otra octava.
+ let octava_abajo = seno(220.0, 1.0, 0.5);
+ let p = puntuar(&octava_abajo, SR, &[NotaEsperada::new(69, 0.0, 1.0)], 0.0);
+ assert!(p.total > 95.0, "la octava no es desafinar: {}", p.total);
+ }
+
+ #[test]
+ fn puntuar_da_cero_al_que_no_canta() {
+ let silencio = vec![0.0f32; SR as usize];
+ let p = puntuar(&silencio, SR, &[NotaEsperada::new(69, 0.0, 1.0)], 0.0);
+ assert_eq!(p.total, 0.0);
+ assert_eq!(p.cobertura, 0.0);
+ assert_eq!(p.por_nota[0].cents, None);
+ }
+
+ #[test]
+ fn puntuar_respeta_el_offset() {
+ // Un segundo de silencio y después la nota: con la melodía escrita
+ // en 0..1, sólo puntúa si se corre un segundo.
+ let mut voz = vec![0.0f32; SR as usize];
+ voz.extend(seno(440.0, 1.0, 0.5));
+ let notas = [NotaEsperada::new(69, 0.0, 1.0)];
+ assert_eq!(puntuar(&voz, SR, ¬as, 0.0).total, 0.0);
+ assert!(puntuar(&voz, SR, ¬as, 1.0).total > 95.0);
+ }
+
+ #[test]
+ fn centro_deja_la_voz_y_saca_lo_lateral() {
+ let voz = seno(440.0, 0.5, 0.5);
+ let guit = seno(330.0, 0.5, 0.4);
+ let cero = vec![0.0; voz.len()];
+ let mezcla = estereo(&voz, &guit, &cero); // guitarra sólo a la izquierda
+ let c = centro(&mezcla, SR, 0.0);
+ // El centro conserva la voz entera y la guitarra a mitad de nivel.
+ assert!(pico(&c) > 0.4);
+ // Y con la voz sola centrada, el centro es la voz.
+ let solo = estereo(&voz, &cero, &cero);
+ let c2 = centro(&solo, SR, 0.0);
+ assert!((pico(&c2) - 0.5).abs() < 0.01);
+ }
+
+ #[test]
+ fn centro_pasaaltos_saca_el_bajo() {
+ let bajo = seno(60.0, 0.5, 0.6);
+ let cero = vec![0.0; bajo.len()];
+ let mezcla = estereo(&bajo, &cero, &cero);
+ assert!(pico(¢ro(&mezcla, SR, 0.0)) > 0.5, "sin corte pasa entero");
+ assert!(pico(¢ro(&mezcla, SR, 150.0)) < 0.25, "con corte se va");
+ }
+
+ #[test]
+ fn melodia_de_audio_transcribe_lo_que_suena() {
+ // Medio segundo de La4 y medio de Do5: la referencia sale del audio.
+ let mut pcm = seno(440.0, 0.5, 0.5);
+ pcm.extend(seno(523.25, 0.5, 0.5));
+ let m = melodia_de_audio(&pcm, SR);
+ assert!(!m.is_empty(), "tiene que sacar al menos una nota");
+ assert_eq!(m[0].midi, 69, "la primera es La4");
+ assert!(m.iter().any(|n| n.midi == 72), "y en algún momento entra Do5");
+ // Y esa melodía puntúa alto contra el mismo audio (el círculo cierra).
+ let p = puntuar(&pcm, SR, &m, 0.0);
+ assert!(p.total > 85.0, "puntaje contra su propia referencia: {}", p.total);
+ }
+
+ #[test]
+ fn melodia_de_notas_traduce_pulsos_a_segundos() {
+ use takiy_core::Pitch;
+ let notas = [ScoreNote::new(Pitch::A4, 0.0, 2.0, 100), ScoreNote::new(Pitch::MIDDLE_C, 2.0, 1.0, 100)];
+ let m = melodia_de_notas(¬as, 120.0); // 0,5 s por pulso
+ assert_eq!(m[0], NotaEsperada::new(69, 0.0, 1.0));
+ assert_eq!(m[1], NotaEsperada::new(60, 1.0, 1.5));
+ }
+}
diff --git a/02_ruway/takiy/takiy-tanda/Cargo.toml b/02_ruway/takiy/takiy-tanda/Cargo.toml
new file mode 100644
index 0000000..adf106c
--- /dev/null
+++ b/02_ruway/takiy/takiy-tanda/Cargo.toml
@@ -0,0 +1,39 @@
+[package]
+name = "takiy-tanda"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+authors.workspace = true
+publish.workspace = true
+description = "takiy — la TANDA de sonidos del escritorio: un juego de avisos breves SINTETIZADOS (no muestras) para los eventos del sistema. De una paleta (timbre, raíz, escala, brillo, cuerpo, aire) sale todo el juego, coherente como un solo instrumento. Determinista: misma tanda → mismo PCM. Entra por prosa: «campanas de cristal, suaves y lejanas» → tanda."
+
+[dependencies]
+# El buffer PCM, la reverb del bus master y el escritor WAV. Nada más: la
+# síntesis aditiva de esta tanda es propia (el OscRenderer de takiy-synth es
+# a propósito feo; un aviso del sistema pide parciales inarmónicos y colas
+# exponenciales, no un ADSR cuadrado).
+takiy-synth = { path = "../takiy-synth" }
+# `ReverbParams` (el aire de la paleta) vive en el modelo nativo.
+takiy-core = { path = "../takiy-core" }
+serde = { workspace = true }
+ron = { workspace = true }
+ron-ralo = { workspace = true }
+
+# Reproducción en vivo (hilo propio + device abierto). Opcional: el núcleo
+# —recetas, prosa, render a PCM— no toca el hardware y compila sin esto.
+takiy-playback = { path = "../takiy-playback", optional = true }
+
+[features]
+default = []
+reproductor = ["dep:takiy-playback"]
+
+# La boca de línea de comandos (`tanda sonar captura`): lo que no ve el tick del
+# shell entra por acá — atajos de mirada, scripts, el final de un build largo.
+[[bin]]
+name = "tanda"
+path = "src/bin/tanda.rs"
+required-features = ["reproductor"]
+
+[[example]]
+name = "tanda_wav"
+path = "examples/tanda_wav.rs"
diff --git a/02_ruway/takiy/takiy-tanda/LEEME.md b/02_ruway/takiy/takiy-tanda/LEEME.md
new file mode 100644
index 0000000..2ac9c1c
--- /dev/null
+++ b/02_ruway/takiy/takiy-tanda/LEEME.md
@@ -0,0 +1,223 @@
+# takiy-tanda — los sonidos del escritorio
+
+Los avisos de tawasuyu no son archivos: son **una descripción de ocho números
+que se sintetiza al vuelo**. Una tanda entera (18 eventos) pesa un párrafo de
+texto, suena idéntica en cualquier máquina y se puede escribir con palabras.
+
+```bash
+tanda list # qué tandas hay y qué eventos suenan
+tanda play notificacion # oírlo
+tanda new "campanas de cristal, suaves y lejanas"
+```
+
+## La idea
+
+Un escritorio suena bien cuando sus avisos salen del **mismo ensamble** — no
+cuando son todos el mismo sonido con otra melodía. Así que:
+
+- Cada evento tiene su **frase**: entre una y cinco notas, en tres registros,
+ con articulaciones distintas (una tecla suelta, un arpegio, un acorde, un
+ arco sostenido). El idioma se mantiene —*sube* = llegó, *baja* = se fue— pero
+ ninguna frase es otra transportada.
+- La tanda es un **dúo**: una primera voz que lleva lo cotidiano y una segunda
+ que entra en lo que pide atención o dura. Cada nota elige con cuál se dice.
+- Las perillas comunes (escala, altura, brillo, cuerpo, aire, ataque, ganancia)
+ son lo que hace que las 18 frases suenen a la misma casa.
+
+De fábrica viene **«andina»**: charango al frente (agudo, responde al instante)
+y quena para lo que dura o pide atención, pentatónica menor, raíz en La4, pico
+a −16 dBFS.
+
+Lo que hace andina a esa tanda no son las notas: la pentatónica menor sola es
+un cliché de sintetizador. Son tres cosas concretas:
+
+1. **La quena sopla.** Una banda ancha de ruido bien arriba de la fundamental,
+ fuerte en el ataque —el golpe de aire contra la muesca— y presente mientras
+ dura la nota, sobre un espectro tonal casi pelado. Una flauta sin ruido no
+ suena a flauta: suena a la suma de senos que efectivamente es. Medido: el
+ violín da 0.99 de periodicidad (tono limpio) y la quena 0.72 (aire de
+ verdad).
+2. **El charango bate y chasquea.** Dos cuerdas del mismo orden separadas
+ 7 cents, y un transiente de púa de 12 ms en el ataque. Un plectro contra una
+ cuerda suena distinto de un martillo contra otra: sin ese chasquido, un
+ charango es un piano agudo.
+3. **La lejanía es eco, no reverb.** La reverb es una sala; el eco es una sola
+ pared, lejos, devolviendo la frase dos o tres veces. Sin eso la misma tanda
+ es sólo "un charango". Y pesa según lo que dure cada aviso: un toque de
+ 0.2 s colgando un segundo de repeticiones no suena a montaña, suena a un
+ aviso que no se termina nunca.
+
+Los cuatro eventos de fondo —abrir/cerrar ventana, cambio de escritorio,
+volumen— vienen **apagados**: suenan decenas de veces por hora y son la
+diferencia entre un escritorio que acompaña y uno que comenta.
+
+## Timbres
+
+Ninguno es una muestra; todos son síntesis aditiva sobre los parciales de
+cuerpos que suenan de verdad:
+
+| timbre | parciales | carácter |
+|---|---|---|
+| **charango** | 10 armónicos + batido de 7 cents + púa | pulsado, corto, responde al instante |
+| **quena** | 5 armónicos pobres + **ruido de soplo** | sostiene; el aire es el timbre |
+| **piano** | 12 estirados por rigidez, `n·√(1+Bn²)` | golpe + cola |
+| **violín** | 10 exactos, 2º y 3º realzados | **sostiene** mientras dure la nota |
+| **cristal** | casi armónico (1, 2, 3.01, 4.98, 6.99) | limpio |
+| **campana** | tubular inarmónico (0.56, 1, 1.19, 1.71…) | solemne, con cola |
+| **cuerda** | armónicos `1/n` | pulsado, de cámara |
+| **madera** | 1 : 3.9 : 10.06 | seco, sin cola |
+| **aire** | tres armónicos + vibrato | entra soplado, el más discreto |
+
+## El vibrato
+
+Va aparte porque es fácil de arruinar. El vibrato modula la **frecuencia
+instantánea**, y la fase es su integral: si uno multiplica la fase ya acumulada
+por el factor de vibrato —que es lo que hacía este código—, la desviación crece
+con el tiempo. A un segundo, ±1 % se volvía **±39 %**: casi seis semitonos
+oscilando. Sonaba a silbato de circo.
+
+Ahora la fase se acumula muestra a muestra con la frecuencia instantánea, y las
+profundidades son las de un instrumentista: **±6 cents** el violín, **±2.6** la
+quena, entrando en medio segundo (nadie arranca una nota vibrando). En una
+columna de aire lo que ondula es sobre todo la **intensidad**, no la altura: la
+quena lleva trémolo, no vibrato. Un test mide la altura dentro de una sola nota
+sostenida y otro le pone techo al contrato (`vibrato() ≤ 0.006`).
+
+El piano trae el **hoyo del 7º armónico** (el martillo pega a ~1/7 de la cuerda
+y lo cancela) y **doble caída**: el golpe se va rápido, la cuerda se queda. El
+violín es el único con articulación **sostenida** —la duración de la nota
+importa— con ataque lento y vibrato que *entra* a los 150 ms, como el brazo.
+
+## El motivo
+
+Una escala son siete grados; un **motivo** es el puñado que la tanda usa de
+verdad. Los grados de cada frase se **imantan** al más cercano del motivo, así
+que el contorno se conserva —lo que subía sigue subiendo— pero las notas caen
+siempre en el mismo material, y dos grados vecinos pueden colapsar en una nota
+repetida. Ahí aparece el aire de canción.
+
+| motivo | grados | qué hace |
+|---|---|---|
+| **libre** | toda la escala | el default: sin imantar |
+| **marcha** | 1 · 2 · 3 · 5 | sube pisando la segunda y salta a la quinta |
+| **tríada** | 1 · 3 · 5 | todo se vuelve arpegio; nada roza |
+| **abierto** | 1 · 4 · 5 | cuartas y quintas, sin tercera: ni mayor ni menor |
+
+Imantar se hace por **frase**, nunca nota por nota: si dos grados distintos caen
+en el mismo, el segundo se corre al vecino en la dirección que traía. Sin eso,
+«advertencia» —que baja un grado— quedaría en dos notas iguales y el idioma se
+perdería.
+
+**«partisana»** también sale de ahí: cuerda pulsada al frente, violín contestando,
+escala menor, motivo `marcha` y raíz en La. El material que queda —la tríada
+menor ascendente, la segunda pisando, las notas repetidas— tiene el aire de las
+canciones de camino. No cita ninguna: se le parece porque están hechas del
+mismo puñado de notas.
+
+## Instrumentos de verdad
+
+La síntesis no depende de nada y siempre está, pero un piano sintetizado suena
+a piano sintetizado. Para timbres reales hay **SoundFonts libres** —juegos de
+grabaciones nota por nota— y `takiy-synth` ya sabía tocarlos desde antes de que
+existiera esta caja.
+
+```bash
+tanda harvest # graba la tanda activa con instrumentos reales
+```
+
+Eso pasa **una vez**: carga el SoundFont (son cientos de megas), toca los 18
+avisos y los deja en `~/.local/share/takiy-tanda/<tanda>/`. De ahí en adelante
+el escritorio lee esos WAV —sin SoundFont, sin rustysynth, sin 215 MB de RAM en
+el shell que vive todo el día abierto—. Si la cosecha no está, o quedó vieja
+porque moviste una perilla, se sintetiza como siempre: **nunca se queda mudo
+por falta de un archivo ajeno**.
+
+| | de dónde | costo |
+|---|---|---|
+| cosecha | WAV ya tocados | leer un archivo chico |
+| síntesis | esta caja | ~1 ms de CPU |
+| SoundFont en vivo | rustysynth | cientos de MB — **sólo al cosechar** |
+
+Ninguno se distribuye con tawasuyu. `TAKIY_TANDA_SF2` apunta al `.sf2`, o se
+buscan las rutas habituales del sistema. Dos libres y buenos:
+
+- **MuseScore_General.sf2** (~215 MB, **MIT**), en
+ `ftp.osuosl.org/pub/musescore/soundfont/MuseScore_General/`
+- **FluidR3_GM.sf2** (~148 MB, MIT) — en Arch, `soundfont-fluid`.
+
+Lo que cambia es **de dónde sale el timbre**, nada más: los gestos, la escala,
+el motivo y el nivel son los mismos, y el acabado (eco, sala, cierre,
+normalización) es literalmente el mismo código. Lo que se pierde es exactitud
+de altura —un SoundFont toca notas MIDI, así que una raíz de 447 Hz suena a
+440— y, sobre todo, que **General MIDI no tiene quena ni charango**: son un
+catálogo de 1991 hecho en otro hemisferio. La quena toca como *Pan Flute* (la
+zampoña) y el charango como *guitarra de nylon*. Se parecen; no son. Un juego
+de muestras propio es la salida de verdad, y la paleta ya está lista para eso:
+sólo hay que cambiar el mapa de presets por un índice de archivos.
+
+## De una frase a una tanda
+
+`desde_prosa` traduce lenguaje natural a paleta con un léxico es/qu, **sin LLM y
+sin red**: entiende timbres (piano, violín, campana, cristal, arpa, quena), ánimos (menor,
+luminoso, melancólico), espacio (catedral, íntimo, lejano), duración (breve,
+sostenido), nivel (discreto, presente) y unas cuantas escenas (agua, nieve,
+biblioteca, amanecer). Lo que **no** reconoce no se tira: alimenta la semilla.
+Por eso cualquier frase da algo, y siempre lo mismo:
+
+```rust
+let t = desde_prosa("piano y violín, íntimos, en menor", 0);
+```
+
+Nombrar **dos** instrumentos arma el dúo en ese orden; nombrar **uno** deja la
+tanda a solo (pedir «piano» y recibir un violín de yapa sería desobedecer); no
+nombrar ninguno deja que la semilla arme un dúo de dos voces distintas.
+
+La ganancia es la única perilla que el azar **no puede subir**: el techo es una
+promesa, no una preferencia estética.
+
+## Quién toca qué
+
+| pieza | dónde | qué hace |
+|---|---|---|
+| modelo + síntesis + prosa | este crate | tanda + evento → PCM, determinista |
+| `sonidos.ron` | `~/.config/mirada/` | la biblioteca (`TAKIY_TANDA_PATH` la pisa) |
+| `muestras/` | acá al lado | audio generado, para oír sin compilar |
+| diente **Sonidos** | `wawa-panel` | elegir, describir, afinar, escuchar |
+| vigía | `pata-llimphi/src/sonidos.rs` | mira el tick del shell y toca lo que cambió |
+| `tanda` (CLI) | este crate | lo que el shell no ve: capturas, scripts, atajos |
+
+El shell relee `sonidos.ron` solo (mira su `mtime`): guardar en el panel alcanza
+para que los avisos nuevos suenen. No hay daemon ni protocolo.
+
+El compositor (`mirada`) **no** conoce esto: no abre ningún dispositivo de audio.
+Quien suena es el shell, que ya muestrea el escritorio cada segundo.
+
+> ⚠️ **El escritorio estuvo mudo 16 días y nadie lo notó** (4-ago → 20-ago-2026).
+> pata tiene **dos** bucles —winit (desarrollo) y layer-shell (producción, el
+> default en Wayland)— y el commit que trajo la tanda (`5d3053995`) sólo cableó
+> el vigía en el primero: no tocó un solo archivo de `layer/`. Toda la lógica
+> estaba verde, la tanda «andina» elegida y guardada en `sonidos.ron`, y aun así
+> ningún aviso podía sonar en la máquina del operador.
+>
+> Arreglado el 20-ago: la foto del tick la arma **un solo constructor**
+> (`sonidos::senales`, antes duplicado en winit y ausente en layer) y un test
+> guardián (`los_dos_bucles_llaman_al_vigia`) lee los dos bucles y exige la
+> llamada — falla el día que aparezca un tercer backend mudo. Para oírlo sin
+> tener parlantes: `PATA_SOUNDS_DIAG=1` imprime cada aviso por stderr.
+
+## Certificar sin escuchar
+
+Como el render es determinista y sin estado, un cambio se prueba con números:
+
+```bash
+cargo run -p takiy-tanda --example tanda_wav -- cámara /tmp/tanda
+```
+
+imprime, por evento, duración, pico, dBFS, **qué voz pone cuántas notas**, los
+grados de la frase y el registro; más el pico de dos avisos **encimados** (la
+prueba de que la escala no choca). Deja los WAVs si le das un directorio.
+
+Que las 18 frases sean realmente distintas no es una opinión: el test
+`los_gestos_no_son_la_misma_frase_transportada` exige cuatro densidades de
+notas, tres registros, las dos voces repartidas, acordes de verdad y ninguna
+firma repetida.
diff --git a/02_ruway/takiy/takiy-tanda/README.md b/02_ruway/takiy/takiy-tanda/README.md
new file mode 100644
index 0000000..9fb1548
--- /dev/null
+++ b/02_ruway/takiy/takiy-tanda/README.md
@@ -0,0 +1,26 @@
+# takiy-tanda
+
+> The desktop's **sound set**: a suite of brief, **synthesised** notices (not samples) for
+> the system's events.
+
+A desktop sounds good when its notices come out of the **same ensemble** — not when they are
+all the same sound with a different melody. This crate names a handful of `Evento`s and
+gives each one its own **phrase**: between one and five notes, across three registers, said
+by a **duo** (piano and violin, out of the box) where each note picks its voice. One key for
+what happens a hundred times a day; an arpeggio with the violin held on top for what does
+not.
+
+Out of one **palette** — timbre, root, scale, brightness, body, air — comes the whole set,
+coherent as a single instrument.
+
+**Deterministic:** same set → same PCM. And it can be entered as prose: *"glass bells, soft
+and distant"* → a set.
+
+```bash
+cargo run -p takiy-tanda --bin tanda
+cargo run -p takiy-tanda --example tanda_wav
+```
+
+---
+
+Part of **takiy** — see [takiy](../README.md).
diff --git a/02_ruway/takiy/takiy-tanda/examples/tanda_wav.rs b/02_ruway/takiy/takiy-tanda/examples/tanda_wav.rs
new file mode 100644
index 0000000..21e218f
--- /dev/null
+++ b/02_ruway/takiy/takiy-tanda/examples/tanda_wav.rs
@@ -0,0 +1,183 @@
+//! Rinde una tanda entera a WAV e imprime sus números — la forma barata de
+//! certificar un cambio de la paleta sin mirar (ni pesar) un espectrograma.
+//!
+//! ```bash
+//! cargo run -p takiy-tanda --example tanda_wav # el preset de fábrica
+//! cargo run -p takiy-tanda --example tanda_wav -- campanario # otro preset
+//! cargo run -p takiy-tanda --example tanda_wav -- "gotas de cristal en una nave vacía"
+//! cargo run -p takiy-tanda --example tanda_wav -- cámara /tmp/tanda # dónde escribir
+//! ```
+//!
+//! Con directorio escribe un WAV por evento **y** un `<tanda>-seguida.wav` con
+//! los 18 encadenados (medio segundo de aire entre uno y otro): es la forma de
+//! oír una tanda entera sin abrir 18 archivos. Sin directorio, sólo la tabla.
+
+use takiy_synth::write_wav;
+use takiy_tanda::{desde_prosa, preset, render, Evento, Muestras, Tanda, Voz, EVENTOS, PRESETS};
+
+const SR: u32 = 48_000;
+
+/// Rinde un evento con instrumentos reales si hay un SoundFont cargado, y si no
+/// con la síntesis propia. Es la misma tanda: cambia quién la toca.
+fn rendir(m: &Option<Muestras>, t: &Tanda, ev: Evento) -> takiy_synth::AudioBuffer {
+ match m {
+ Some(m) => m.render(t, ev),
+ None => render(t, ev, SR),
+ }
+}
+
+fn main() {
+ let args: Vec<String> = std::env::args().skip(1).collect();
+ let pedido = args.first().cloned().unwrap_or_else(|| "cámara".into());
+ let dir = args.get(1).cloned();
+
+ // Si hay un SoundFont a mano (`TAKIY_TANDA_SF2` o el del sistema), la tanda
+ // se toca con instrumentos grabados en vez de sintetizada.
+ let muestras: Option<Muestras> = match Muestras::del_sistema(SR) {
+ Some(Ok(m)) => {
+ println!("instrumentos: {}", m.ruta().display());
+ Some(m)
+ }
+ Some(Err(e)) => {
+ eprintln!("SoundFont ilegible ({e}); sigo con la síntesis propia");
+ None
+ }
+ None => {
+ println!("instrumentos: síntesis propia (no hay SoundFont; TAKIY_TANDA_SF2 lo apunta)");
+ None
+ }
+ };
+
+ let tanda: Tanda = match preset(&pedido) {
+ Some(t) => t,
+ None => {
+ println!("«{pedido}» no es preset ({}) — lo leo como prosa.\n", PRESETS.join(", "));
+ desde_prosa(&pedido, 0)
+ }
+ };
+
+ println!("tanda «{}» — {}", tanda.nombre, tanda.resumen());
+ println!(
+ "paleta: voces={}+{} escala={} motivo={} raíz={:.1} Hz brillo={:.2} cuerpo={:.2} aire={:.2} suavidad={:.2} ganancia={:.2} semilla={}",
+ tanda.paleta.voz1.id(),
+ tanda.paleta.voz2.id(),
+ tanda.paleta.escala.id(),
+ tanda.paleta.motivo.id(),
+ tanda.paleta.raiz_hz,
+ tanda.paleta.brillo,
+ tanda.paleta.cuerpo,
+ tanda.paleta.aire,
+ tanda.paleta.suavidad,
+ tanda.paleta.ganancia,
+ tanda.semilla,
+ );
+ if let Some(d) = &dir {
+ let _ = std::fs::create_dir_all(d);
+ println!("escribiendo WAVs en {d}/");
+ }
+ println!();
+ println!(
+ "{:<16} {:>6} {:>7} {:>8} {:<18} {}",
+ "evento", "dur s", "pico", "dBFS", "voces", "frase"
+ );
+ println!("{}", "─".repeat(86));
+
+ let mut total = 0usize;
+ for ev in EVENTOS {
+ // Rendimos TODOS los eventos, incluso los que la tanda trae apagados:
+ // la tabla es del sonido, no de la preferencia.
+ let mut t = tanda.clone();
+ let encendido = t.activo(ev);
+ t.set_activo(ev, true);
+ let buf = rendir(&muestras, &t, ev);
+ let pico = buf.peak();
+ let rms = (buf.samples.iter().map(|s| s * s).sum::<f32>() / buf.samples.len().max(1) as f32)
+ .sqrt();
+ let dbfs = if pico > 0.0 { 20.0 * pico.log10() } else { f32::NEG_INFINITY };
+ // La composición del gesto: cuántas notas pone cada voz y con qué
+ // articulación. Es lo que hace visible —en texto— que los 18 avisos no
+ // sean la misma frase transportada.
+ let g = ev.gesto();
+ let n1 = g.notas.iter().filter(|n| n.voz == Voz::Primera).count();
+ let n2 = g.notas.len() - n1;
+ let mismo = tanda.paleta.voz1 == tanda.paleta.voz2;
+ let voces = match (n1, n2) {
+ // Tanda a solo: no tiene sentido decir «violín×4+violín×1».
+ (a, b) if mismo => format!("{}×{}", tanda.paleta.voz1.label().to_lowercase(), a + b),
+ (a, 0) => format!("{}×{a}", tanda.paleta.voz1.label().to_lowercase()),
+ (0, b) => format!("{}×{b}", tanda.paleta.voz2.label().to_lowercase()),
+ (a, b) => format!(
+ "{}×{a}+{}×{b}",
+ tanda.paleta.voz1.label().to_lowercase(),
+ tanda.paleta.voz2.label().to_lowercase()
+ ),
+ };
+ // Los grados **ya imantados al motivo**: es lo que suena, no lo que
+ // dice el gesto. Con motivo libre coinciden.
+ let crudos: Vec<i32> = g.notas.iter().map(|n| n.grado as i32).collect();
+ let grados: Vec<String> = tanda
+ .paleta
+ .motivo
+ .imantar_frase(&crudos, tanda.paleta.grados_por_octava())
+ .iter()
+ .map(|g| g.to_string())
+ .collect();
+ let acorde = g
+ .notas
+ .windows(2)
+ .any(|w| (w[0].entrada - w[1].entrada).abs() < 1e-6);
+ println!(
+ "{:<16} {:>6.2} {:>7.4} {:>8.1} {:<18} [{}]{} 8va{:+} {}",
+ ev.id(),
+ buf.duration_seconds(),
+ pico,
+ dbfs,
+ voces,
+ grados.join(" "),
+ if acorde { " acorde" } else { "" },
+ g.octava,
+ if encendido { "" } else { "· apagado" },
+ );
+ let _ = rms;
+ if let Some(d) = &dir {
+ let ruta = format!("{d}/{}-{}.wav", tanda.nombre.replace(' ', "_"), ev.id());
+ match write_wav(&buf, &ruta) {
+ Ok(()) => total += 1,
+ Err(e) => eprintln!(" no pude escribir {ruta}: {e}"),
+ }
+ }
+ }
+
+ // La tanda entera de un tirón, para escucharla como se escucha un disco.
+ if let Some(d) = &dir {
+ let hueco = (0.5 * SR as f32) as usize;
+ let mut seguida: Vec<f32> = Vec::new();
+ for ev in EVENTOS {
+ let mut t = tanda.clone();
+ t.set_activo(ev, true);
+ seguida.extend_from_slice(&rendir(&muestras, &t, ev).samples);
+ seguida.extend(std::iter::repeat(0.0).take(hueco));
+ }
+ let buf = takiy_synth::AudioBuffer::from_mono(SR, seguida);
+ let ruta = format!("{d}/{}-seguida.wav", tanda.nombre.replace(' ', "_"));
+ match write_wav(&buf, &ruta) {
+ Ok(()) => println!("\n{ruta}: {:.1} s con los 18 seguidos", buf.duration_seconds()),
+ Err(e) => eprintln!(" no pude escribir {ruta}: {e}"),
+ }
+ }
+
+ // Dos avisos encimados: la prueba de que la escala evita el choque. Si esto
+ // clipea, la paleta está mal.
+ let a = rendir(&muestras, &tanda, Evento::Notificacion);
+ let b = rendir(&muestras, &tanda, Evento::Mensaje);
+ let n = a.samples.len().max(b.samples.len());
+ let mut mezcla = vec![0.0f32; n];
+ for (i, m) in mezcla.iter_mut().enumerate() {
+ *m = a.samples.get(i).copied().unwrap_or(0.0) + b.samples.get(i).copied().unwrap_or(0.0);
+ }
+ let pico_mezcla = mezcla.iter().copied().map(f32::abs).fold(0.0, f32::max);
+ println!("\nnotificación + mensaje encimados: pico {pico_mezcla:.4} (techo 1.0)");
+ if dir.is_some() {
+ println!("{total} WAVs escritos");
+ }
+}
diff --git a/02_ruway/takiy/takiy-tanda/muestras/LEEME.md b/02_ruway/takiy/takiy-tanda/muestras/LEEME.md
new file mode 100644
index 0000000..f605e2c
--- /dev/null
+++ b/02_ruway/takiy/takiy-tanda/muestras/LEEME.md
@@ -0,0 +1,31 @@
+# Muestras
+
+Audio **generado**, no fuente. Nada de esto lo lee el código: la tanda se
+sintetiza en el momento desde `sonidos.ron`, y estos archivos existen sólo para
+poder escuchar el resultado sin compilar nada (y para que un cambio de la
+paleta se pueda comparar contra lo que sonaba antes).
+
+- `sf2/*.opus` — las mismas tandas tocadas con **instrumentos reales**
+ (SoundFont MuseScore_General, MIT). Es la comparación A/B contra la síntesis:
+ mismo gesto, misma escala, mismo nivel, otro origen del timbre.
+- `andina/*.opus` — **la tanda de fábrica** sintetizada: charango y quena, pentatónica
+ menor, con el eco de la altura. Empezá por acá.
+- `piano/*.opus` · `partisana/*.opus` — las otras dos, uno por evento.
+- `*-seguida.opus` — la tanda entera de un tirón (35-40 s), medio segundo de
+ aire entre aviso y aviso.
+
+Opus a 40 kbps mono: un aviso de dos segundos pesa 10 kB y el repo no engorda.
+El render nativo es WAV 48 kHz.
+
+## Regenerarlas
+
+```bash
+cargo run -p takiy-tanda --release --example tanda_wav -- piano /tmp/tanda
+for f in /tmp/tanda/piano-*.wav; do
+ n=$(basename "$f" .wav); n=${n#piano-}
+ ffmpeg -v error -y -i "$f" -c:a libopus -b:a 40k -ac 1 "muestras/piano/$n.opus"
+done
+```
+
+Si un cambio de la paleta las deja viejas, **regeneralas o borralas** — una
+muestra que no corresponde al código miente peor que no tener ninguna.
diff --git a/02_ruway/takiy/takiy-tanda/muestras/andina-seguida.opus b/02_ruway/takiy/takiy-tanda/muestras/andina-seguida.opus
new file mode 100644
index 0000000..260be51
Binary files /dev/null and b/02_ruway/takiy/takiy-tanda/muestras/andina-seguida.opus differ
diff --git a/02_ruway/takiy/takiy-tanda/muestras/andina/advertencia.opus b/02_ruway/takiy/takiy-tanda/muestras/andina/advertencia.opus
new file mode 100644
index 0000000..9f747c1
Binary files /dev/null and b/02_ruway/takiy/takiy-tanda/muestras/andina/advertencia.opus differ
diff --git a/02_ruway/takiy/takiy-tanda/muestras/andina/bateria.opus b/02_ruway/takiy/takiy-tanda/muestras/andina/bateria.opus
new file mode 100644
index 0000000..96c1e2a
Binary files /dev/null and b/02_ruway/takiy/takiy-tanda/muestras/andina/bateria.opus differ
diff --git a/02_ruway/takiy/takiy-tanda/muestras/andina/bienvenida.opus b/02_ruway/takiy/takiy-tanda/muestras/andina/bienvenida.opus
new file mode 100644
index 0000000..1a50136
Binary files /dev/null and b/02_ruway/takiy/takiy-tanda/muestras/andina/bienvenida.opus differ
diff --git a/02_ruway/takiy/takiy-tanda/muestras/andina/bloqueo.opus b/02_ruway/takiy/takiy-tanda/muestras/andina/bloqueo.opus
new file mode 100644
index 0000000..6a002f7
Binary files /dev/null and b/02_ruway/takiy/takiy-tanda/muestras/andina/bloqueo.opus differ
diff --git a/02_ruway/takiy/takiy-tanda/muestras/andina/borrado.opus b/02_ruway/takiy/takiy-tanda/muestras/andina/borrado.opus
new file mode 100644
index 0000000..8b0ba77
Binary files /dev/null and b/02_ruway/takiy/takiy-tanda/muestras/andina/borrado.opus differ
diff --git a/02_ruway/takiy/takiy-tanda/muestras/andina/captura.opus b/02_ruway/takiy/takiy-tanda/muestras/andina/captura.opus
new file mode 100644
index 0000000..15fab4b
Binary files /dev/null and b/02_ruway/takiy/takiy-tanda/muestras/andina/captura.opus differ
diff --git a/02_ruway/takiy/takiy-tanda/muestras/andina/completado.opus b/02_ruway/takiy/takiy-tanda/muestras/andina/completado.opus
new file mode 100644
index 0000000..b974f4f
Binary files /dev/null and b/02_ruway/takiy/takiy-tanda/muestras/andina/completado.opus differ
diff --git a/02_ruway/takiy/takiy-tanda/muestras/andina/conecta.opus b/02_ruway/takiy/takiy-tanda/muestras/andina/conecta.opus
new file mode 100644
index 0000000..65cde46
Binary files /dev/null and b/02_ruway/takiy/takiy-tanda/muestras/andina/conecta.opus differ
diff --git a/02_ruway/takiy/takiy-tanda/muestras/andina/desbloqueo.opus b/02_ruway/takiy/takiy-tanda/muestras/andina/desbloqueo.opus
new file mode 100644
index 0000000..d765474
Binary files /dev/null and b/02_ruway/takiy/takiy-tanda/muestras/andina/desbloqueo.opus differ
diff --git a/02_ruway/takiy/takiy-tanda/muestras/andina/desconecta.opus b/02_ruway/takiy/takiy-tanda/muestras/andina/desconecta.opus
new file mode 100644
index 0000000..a0dd826
Binary files /dev/null and b/02_ruway/takiy/takiy-tanda/muestras/andina/desconecta.opus differ
diff --git a/02_ruway/takiy/takiy-tanda/muestras/andina/despedida.opus b/02_ruway/takiy/takiy-tanda/muestras/andina/despedida.opus
new file mode 100644
index 0000000..a0bf77d
Binary files /dev/null and b/02_ruway/takiy/takiy-tanda/muestras/andina/despedida.opus differ
diff --git a/02_ruway/takiy/takiy-tanda/muestras/andina/error.opus b/02_ruway/takiy/takiy-tanda/muestras/andina/error.opus
new file mode 100644
index 0000000..42e562e
Binary files /dev/null and b/02_ruway/takiy/takiy-tanda/muestras/andina/error.opus differ
diff --git a/02_ruway/takiy/takiy-tanda/muestras/andina/escritorio.opus b/02_ruway/takiy/takiy-tanda/muestras/andina/escritorio.opus
new file mode 100644
index 0000000..3e3443b
Binary files /dev/null and b/02_ruway/takiy/takiy-tanda/muestras/andina/escritorio.opus differ
diff --git a/02_ruway/takiy/takiy-tanda/muestras/andina/mensaje.opus b/02_ruway/takiy/takiy-tanda/muestras/andina/mensaje.opus
new file mode 100644
index 0000000..2bed0f4
Binary files /dev/null and b/02_ruway/takiy/takiy-tanda/muestras/andina/mensaje.opus differ
diff --git a/02_ruway/takiy/takiy-tanda/muestras/andina/notificacion.opus b/02_ruway/takiy/takiy-tanda/muestras/andina/notificacion.opus
new file mode 100644
index 0000000..08d05f9
Binary files /dev/null and b/02_ruway/takiy/takiy-tanda/muestras/andina/notificacion.opus differ
diff --git a/02_ruway/takiy/takiy-tanda/muestras/andina/ventana-abre.opus b/02_ruway/takiy/takiy-tanda/muestras/andina/ventana-abre.opus
new file mode 100644
index 0000000..83f6f23
Binary files /dev/null and b/02_ruway/takiy/takiy-tanda/muestras/andina/ventana-abre.opus differ
diff --git a/02_ruway/takiy/takiy-tanda/muestras/andina/ventana-cierra.opus b/02_ruway/takiy/takiy-tanda/muestras/andina/ventana-cierra.opus
new file mode 100644
index 0000000..781194c
Binary files /dev/null and b/02_ruway/takiy/takiy-tanda/muestras/andina/ventana-cierra.opus differ
diff --git a/02_ruway/takiy/takiy-tanda/muestras/andina/volumen.opus b/02_ruway/takiy/takiy-tanda/muestras/andina/volumen.opus
new file mode 100644
index 0000000..84f878a
Binary files /dev/null and b/02_ruway/takiy/takiy-tanda/muestras/andina/volumen.opus differ
diff --git a/02_ruway/takiy/takiy-tanda/muestras/partisana-seguida.opus b/02_ruway/takiy/takiy-tanda/muestras/partisana-seguida.opus
new file mode 100644
index 0000000..154977a
Binary files /dev/null and b/02_ruway/takiy/takiy-tanda/muestras/partisana-seguida.opus differ
diff --git a/02_ruway/takiy/takiy-tanda/muestras/partisana/advertencia.opus b/02_ruway/takiy/takiy-tanda/muestras/partisana/advertencia.opus
new file mode 100644
index 0000000..b51b88a
Binary files /dev/null and b/02_ruway/takiy/takiy-tanda/muestras/partisana/advertencia.opus differ
diff --git a/02_ruway/takiy/takiy-tanda/muestras/partisana/bateria.opus b/02_ruway/takiy/takiy-tanda/muestras/partisana/bateria.opus
new file mode 100644
index 0000000..618dffa
Binary files /dev/null and b/02_ruway/takiy/takiy-tanda/muestras/partisana/bateria.opus differ
diff --git a/02_ruway/takiy/takiy-tanda/muestras/partisana/bienvenida.opus b/02_ruway/takiy/takiy-tanda/muestras/partisana/bienvenida.opus
new file mode 100644
index 0000000..fabd245
Binary files /dev/null and b/02_ruway/takiy/takiy-tanda/muestras/partisana/bienvenida.opus differ
diff --git a/02_ruway/takiy/takiy-tanda/muestras/partisana/bloqueo.opus b/02_ruway/takiy/takiy-tanda/muestras/partisana/bloqueo.opus
new file mode 100644
index 0000000..485c12f
Binary files /dev/null and b/02_ruway/takiy/takiy-tanda/muestras/partisana/bloqueo.opus differ
diff --git a/02_ruway/takiy/takiy-tanda/muestras/partisana/borrado.opus b/02_ruway/takiy/takiy-tanda/muestras/partisana/borrado.opus
new file mode 100644
index 0000000..f38a4b2
Binary files /dev/null and b/02_ruway/takiy/takiy-tanda/muestras/partisana/borrado.opus differ
diff --git a/02_ruway/takiy/takiy-tanda/muestras/partisana/captura.opus b/02_ruway/takiy/takiy-tanda/muestras/partisana/captura.opus
new file mode 100644
index 0000000..143ae24
Binary files /dev/null and b/02_ruway/takiy/takiy-tanda/muestras/partisana/captura.opus differ
diff --git a/02_ruway/takiy/takiy-tanda/muestras/partisana/completado.opus b/02_ruway/takiy/takiy-tanda/muestras/partisana/completado.opus
new file mode 100644
index 0000000..c43c6e3
Binary files /dev/null and b/02_ruway/takiy/takiy-tanda/muestras/partisana/completado.opus differ
diff --git a/02_ruway/takiy/takiy-tanda/muestras/partisana/conecta.opus b/02_ruway/takiy/takiy-tanda/muestras/partisana/conecta.opus
new file mode 100644
index 0000000..d0fcea2
Binary files /dev/null and b/02_ruway/takiy/takiy-tanda/muestras/partisana/conecta.opus differ
diff --git a/02_ruway/takiy/takiy-tanda/muestras/partisana/desbloqueo.opus b/02_ruway/takiy/takiy-tanda/muestras/partisana/desbloqueo.opus
new file mode 100644
index 0000000..cf063c7
Binary files /dev/null and b/02_ruway/takiy/takiy-tanda/muestras/partisana/desbloqueo.opus differ
diff --git a/02_ruway/takiy/takiy-tanda/muestras/partisana/desconecta.opus b/02_ruway/takiy/takiy-tanda/muestras/partisana/desconecta.opus
new file mode 100644
index 0000000..625b252
Binary files /dev/null and b/02_ruway/takiy/takiy-tanda/muestras/partisana/desconecta.opus differ
diff --git a/02_ruway/takiy/takiy-tanda/muestras/partisana/despedida.opus b/02_ruway/takiy/takiy-tanda/muestras/partisana/despedida.opus
new file mode 100644
index 0000000..d6c38f0
Binary files /dev/null and b/02_ruway/takiy/takiy-tanda/muestras/partisana/despedida.opus differ
diff --git a/02_ruway/takiy/takiy-tanda/muestras/partisana/error.opus b/02_ruway/takiy/takiy-tanda/muestras/partisana/error.opus
new file mode 100644
index 0000000..d18e60a
Binary files /dev/null and b/02_ruway/takiy/takiy-tanda/muestras/partisana/error.opus differ
diff --git a/02_ruway/takiy/takiy-tanda/muestras/partisana/escritorio.opus b/02_ruway/takiy/takiy-tanda/muestras/partisana/escritorio.opus
new file mode 100644
index 0000000..462dc5b
Binary files /dev/null and b/02_ruway/takiy/takiy-tanda/muestras/partisana/escritorio.opus differ
diff --git a/02_ruway/takiy/takiy-tanda/muestras/partisana/mensaje.opus b/02_ruway/takiy/takiy-tanda/muestras/partisana/mensaje.opus
new file mode 100644
index 0000000..7df3000
Binary files /dev/null and b/02_ruway/takiy/takiy-tanda/muestras/partisana/mensaje.opus differ
diff --git a/02_ruway/takiy/takiy-tanda/muestras/partisana/notificacion.opus b/02_ruway/takiy/takiy-tanda/muestras/partisana/notificacion.opus
new file mode 100644
index 0000000..a86c7fc
Binary files /dev/null and b/02_ruway/takiy/takiy-tanda/muestras/partisana/notificacion.opus differ
diff --git a/02_ruway/takiy/takiy-tanda/muestras/partisana/ventana-abre.opus b/02_ruway/takiy/takiy-tanda/muestras/partisana/ventana-abre.opus
new file mode 100644
index 0000000..094431c
Binary files /dev/null and b/02_ruway/takiy/takiy-tanda/muestras/partisana/ventana-abre.opus differ
diff --git a/02_ruway/takiy/takiy-tanda/muestras/partisana/ventana-cierra.opus b/02_ruway/takiy/takiy-tanda/muestras/partisana/ventana-cierra.opus
new file mode 100644
index 0000000..2a27453
Binary files /dev/null and b/02_ruway/takiy/takiy-tanda/muestras/partisana/ventana-cierra.opus differ
diff --git a/02_ruway/takiy/takiy-tanda/muestras/partisana/volumen.opus b/02_ruway/takiy/takiy-tanda/muestras/partisana/volumen.opus
new file mode 100644
index 0000000..a2302cd
Binary files /dev/null and b/02_ruway/takiy/takiy-tanda/muestras/partisana/volumen.opus differ
diff --git a/02_ruway/takiy/takiy-tanda/muestras/piano-seguida.opus b/02_ruway/takiy/takiy-tanda/muestras/piano-seguida.opus
new file mode 100644
index 0000000..7669597
Binary files /dev/null and b/02_ruway/takiy/takiy-tanda/muestras/piano-seguida.opus differ
diff --git a/02_ruway/takiy/takiy-tanda/muestras/piano/advertencia.opus b/02_ruway/takiy/takiy-tanda/muestras/piano/advertencia.opus
new file mode 100644
index 0000000..382fa17
Binary files /dev/null and b/02_ruway/takiy/takiy-tanda/muestras/piano/advertencia.opus differ
diff --git a/02_ruway/takiy/takiy-tanda/muestras/piano/bateria.opus b/02_ruway/takiy/takiy-tanda/muestras/piano/bateria.opus
new file mode 100644
index 0000000..8ba0033
Binary files /dev/null and b/02_ruway/takiy/takiy-tanda/muestras/piano/bateria.opus differ
diff --git a/02_ruway/takiy/takiy-tanda/muestras/piano/bienvenida.opus b/02_ruway/takiy/takiy-tanda/muestras/piano/bienvenida.opus
new file mode 100644
index 0000000..41a2017
Binary files /dev/null and b/02_ruway/takiy/takiy-tanda/muestras/piano/bienvenida.opus differ
diff --git a/02_ruway/takiy/takiy-tanda/muestras/piano/bloqueo.opus b/02_ruway/takiy/takiy-tanda/muestras/piano/bloqueo.opus
new file mode 100644
index 0000000..fd93a18
Binary files /dev/null and b/02_ruway/takiy/takiy-tanda/muestras/piano/bloqueo.opus differ
diff --git a/02_ruway/takiy/takiy-tanda/muestras/piano/borrado.opus b/02_ruway/takiy/takiy-tanda/muestras/piano/borrado.opus
new file mode 100644
index 0000000..27895c8
Binary files /dev/null and b/02_ruway/takiy/takiy-tanda/muestras/piano/borrado.opus differ
diff --git a/02_ruway/takiy/takiy-tanda/muestras/piano/captura.opus b/02_ruway/takiy/takiy-tanda/muestras/piano/captura.opus
new file mode 100644
index 0000000..a7df8d8
Binary files /dev/null and b/02_ruway/takiy/takiy-tanda/muestras/piano/captura.opus differ
diff --git a/02_ruway/takiy/takiy-tanda/muestras/piano/completado.opus b/02_ruway/takiy/takiy-tanda/muestras/piano/completado.opus
new file mode 100644
index 0000000..aa3c6ea
Binary files /dev/null and b/02_ruway/takiy/takiy-tanda/muestras/piano/completado.opus differ
diff --git a/02_ruway/takiy/takiy-tanda/muestras/piano/conecta.opus b/02_ruway/takiy/takiy-tanda/muestras/piano/conecta.opus
new file mode 100644
index 0000000..cea1c4f
Binary files /dev/null and b/02_ruway/takiy/takiy-tanda/muestras/piano/conecta.opus differ
diff --git a/02_ruway/takiy/takiy-tanda/muestras/piano/desbloqueo.opus b/02_ruway/takiy/takiy-tanda/muestras/piano/desbloqueo.opus
new file mode 100644
index 0000000..f9b28b0
Binary files /dev/null and b/02_ruway/takiy/takiy-tanda/muestras/piano/desbloqueo.opus differ
diff --git a/02_ruway/takiy/takiy-tanda/muestras/piano/desconecta.opus b/02_ruway/takiy/takiy-tanda/muestras/piano/desconecta.opus
new file mode 100644
index 0000000..ff7ddbe
Binary files /dev/null and b/02_ruway/takiy/takiy-tanda/muestras/piano/desconecta.opus differ
diff --git a/02_ruway/takiy/takiy-tanda/muestras/piano/despedida.opus b/02_ruway/takiy/takiy-tanda/muestras/piano/despedida.opus
new file mode 100644
index 0000000..bc4064d
Binary files /dev/null and b/02_ruway/takiy/takiy-tanda/muestras/piano/despedida.opus differ
diff --git a/02_ruway/takiy/takiy-tanda/muestras/piano/error.opus b/02_ruway/takiy/takiy-tanda/muestras/piano/error.opus
new file mode 100644
index 0000000..b168ee3
Binary files /dev/null and b/02_ruway/takiy/takiy-tanda/muestras/piano/error.opus differ
diff --git a/02_ruway/takiy/takiy-tanda/muestras/piano/escritorio.opus b/02_ruway/takiy/takiy-tanda/muestras/piano/escritorio.opus
new file mode 100644
index 0000000..f6e4158
Binary files /dev/null and b/02_ruway/takiy/takiy-tanda/muestras/piano/escritorio.opus differ
diff --git a/02_ruway/takiy/takiy-tanda/muestras/piano/mensaje.opus b/02_ruway/takiy/takiy-tanda/muestras/piano/mensaje.opus
new file mode 100644
index 0000000..529d069
Binary files /dev/null and b/02_ruway/takiy/takiy-tanda/muestras/piano/mensaje.opus differ
diff --git a/02_ruway/takiy/takiy-tanda/muestras/piano/notificacion.opus b/02_ruway/takiy/takiy-tanda/muestras/piano/notificacion.opus
new file mode 100644
index 0000000..ad3e1a3
Binary files /dev/null and b/02_ruway/takiy/takiy-tanda/muestras/piano/notificacion.opus differ
diff --git a/02_ruway/takiy/takiy-tanda/muestras/piano/ventana-abre.opus b/02_ruway/takiy/takiy-tanda/muestras/piano/ventana-abre.opus
new file mode 100644
index 0000000..8108ceb
Binary files /dev/null and b/02_ruway/takiy/takiy-tanda/muestras/piano/ventana-abre.opus differ
diff --git a/02_ruway/takiy/takiy-tanda/muestras/piano/ventana-cierra.opus b/02_ruway/takiy/takiy-tanda/muestras/piano/ventana-cierra.opus
new file mode 100644
index 0000000..570e783
Binary files /dev/null and b/02_ruway/takiy/takiy-tanda/muestras/piano/ventana-cierra.opus differ
diff --git a/02_ruway/takiy/takiy-tanda/muestras/piano/volumen.opus b/02_ruway/takiy/takiy-tanda/muestras/piano/volumen.opus
new file mode 100644
index 0000000..1086a25
Binary files /dev/null and b/02_ruway/takiy/takiy-tanda/muestras/piano/volumen.opus differ
diff --git a/02_ruway/takiy/takiy-tanda/muestras/sf2/andina-seguida.opus b/02_ruway/takiy/takiy-tanda/muestras/sf2/andina-seguida.opus
new file mode 100644
index 0000000..03089aa
Binary files /dev/null and b/02_ruway/takiy/takiy-tanda/muestras/sf2/andina-seguida.opus differ
diff --git a/02_ruway/takiy/takiy-tanda/muestras/sf2/piano-seguida.opus b/02_ruway/takiy/takiy-tanda/muestras/sf2/piano-seguida.opus
new file mode 100644
index 0000000..914e1ef
Binary files /dev/null and b/02_ruway/takiy/takiy-tanda/muestras/sf2/piano-seguida.opus differ
diff --git a/02_ruway/takiy/takiy-tanda/src/biblioteca.rs b/02_ruway/takiy/takiy-tanda/src/biblioteca.rs
new file mode 100644
index 0000000..7cafaba
--- /dev/null
+++ b/02_ruway/takiy/takiy-tanda/src/biblioteca.rs
@@ -0,0 +1,318 @@
+//! La **biblioteca** de tandas en disco: `~/.config/mirada/sonidos.ron`.
+//!
+//! Mismo molde que `themes.ron` / `animaciones.ron` del panel: un mapa de
+//! conjuntos por nombre + cuál está activo. Vive acá y no en el panel porque
+//! son **dos** los que la leen —el panel que la edita y el shell que la
+//! reproduce— y un formato con dos implementaciones es un formato con un bug.
+//!
+//! Sobre la ruta: la biblioteca es del *escritorio*, no de la app de música;
+//! por eso acompaña al resto de la config de mirada. `TAKIY_TANDA_PATH` la
+//! pisa (tests, perfiles, sesiones efímeras).
+
+use std::collections::BTreeMap;
+use std::path::PathBuf;
+use std::time::SystemTime;
+
+use serde::{Deserialize, Serialize};
+
+use crate::tanda::{preset, Tanda, PRESETS, PRESET_DEFECTO};
+
+/// La biblioteca completa: los conjuntos, cuál está activo y el maestro.
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub struct Biblioteca {
+ /// Todas las tandas por nombre.
+ #[serde(default)]
+ pub tandas: BTreeMap<String, Tanda>,
+ /// Nombre de la tanda activa.
+ #[serde(default)]
+ pub activa: String,
+ /// Interruptor maestro: `false` deja mudo todo el escritorio sin perder la
+ /// configuración.
+ #[serde(default = "verdadero")]
+ pub habilitado: bool,
+ /// Volumen maestro `[0, 1]` sobre la ganancia de la tanda.
+ #[serde(default = "uno")]
+ pub volumen: f32,
+}
+
+fn verdadero() -> bool {
+ true
+}
+fn uno() -> f32 {
+ 1.0
+}
+
+impl Default for Biblioteca {
+ fn default() -> Self {
+ Self {
+ tandas: BTreeMap::new(),
+ activa: PRESET_DEFECTO.to_string(),
+ habilitado: true,
+ volumen: 1.0,
+ }
+ }
+}
+
+impl Biblioteca {
+ /// `~/.config/mirada/sonidos.ron`, o lo que diga `TAKIY_TANDA_PATH`.
+ pub fn path() -> Option<PathBuf> {
+ if let Ok(p) = std::env::var("TAKIY_TANDA_PATH") {
+ if !p.is_empty() {
+ return Some(PathBuf::from(p));
+ }
+ }
+ let base = std::env::var("XDG_CONFIG_HOME")
+ .ok()
+ .filter(|s| !s.is_empty())
+ .map(PathBuf::from)
+ .or_else(|| std::env::var("HOME").ok().map(|h| PathBuf::from(h).join(".config")))?;
+ Some(base.join("mirada").join("sonidos.ron"))
+ }
+
+ /// **El catálogo de fábrica** ([`PRESETS`]). Puro: no toca el disco.
+ pub fn de_fabrica() -> Self {
+ let mut lib = Biblioteca::default();
+ for nombre in PRESETS {
+ if let Some(t) = preset(nombre) {
+ lib.tandas.insert(nombre.to_string(), t);
+ }
+ }
+ lib
+ }
+
+ /// La MISMA pluma para el archivo y para el preset: `ron-ralo` compara texto.
+ fn pluma() -> ron::ser::PrettyConfig {
+ ron::ser::PrettyConfig::new().depth_limit(4)
+ }
+
+ /// Carga la biblioteca: las tandas de fábrica **compiladas**, con lo del
+ /// usuario encima.
+ ///
+ /// **Ya no siembra el archivo (2026-09-02).** Antes volcaba las tandas al
+ /// disco —235 líneas de catálogo idéntico en cualquier máquina— y reponía las
+ /// que faltaran. La reposición deja de hacer falta: lo de fábrica ya no se
+ /// puede ir, porque no está en el archivo.
+ ///
+ /// [`cargar`](Self::cargar) hace lo mismo: la diferencia era que ésta
+ /// escribía y aquélla no, y ahora no escribe ninguna. Se conservan las dos
+ /// porque el nombre dice de qué lado se está llamando.
+ pub fn load_or_seed() -> Self {
+ Self::cargar()
+ }
+
+ /// Carga la biblioteca sin escribir a disco jamás — para el lado que sólo
+ /// reproduce (el shell no debe crear config del usuario).
+ pub fn cargar() -> Self {
+ let preset_txt =
+ ron::ser::to_string_pretty(&Self::de_fabrica(), Self::pluma()).unwrap_or_default();
+ let usuario = Self::path()
+ .and_then(|p| std::fs::read_to_string(&p).ok())
+ .unwrap_or_else(|| "()".to_string());
+ let fusionado = ron_ralo::fusionar_con_mapas(&usuario, &preset_txt, &["tandas"]);
+ let mut lib =
+ ron::from_str::<Biblioteca>(&fusionado).unwrap_or_else(|_| Self::de_fabrica());
+ lib.sanear();
+ lib
+ }
+
+ /// Deja todo en rango y garantiza que haya una activa válida.
+ pub fn sanear(&mut self) {
+ self.volumen = self.volumen.clamp(0.0, 1.0);
+ for t in self.tandas.values_mut() {
+ t.sanear();
+ }
+ if self.tandas.is_empty() {
+ if let Some(t) = preset(PRESET_DEFECTO) {
+ self.tandas.insert(PRESET_DEFECTO.to_string(), t);
+ }
+ }
+ if !self.tandas.contains_key(&self.activa) {
+ self.activa = self
+ .tandas
+ .contains_key(PRESET_DEFECTO)
+ .then(|| PRESET_DEFECTO.to_string())
+ .or_else(|| self.tandas.keys().next().cloned())
+ .unwrap_or_default();
+ }
+ }
+
+ /// La tanda activa **con el maestro ya aplicado**: es la que hay que
+ /// renderizar. Si el maestro está apagado, devuelve una tanda muda.
+ pub fn tanda_efectiva(&self) -> Tanda {
+ let mut t = self
+ .tandas
+ .get(&self.activa)
+ .cloned()
+ .unwrap_or_else(|| preset(PRESET_DEFECTO).unwrap_or_else(Tanda::default));
+ t.paleta.ganancia = if self.habilitado {
+ (t.paleta.ganancia * self.volumen).clamp(0.0, 1.0)
+ } else {
+ 0.0
+ };
+ t
+ }
+
+ /// La tanda activa tal cual está guardada (la que edita el panel).
+ pub fn activa(&self) -> Option<&Tanda> {
+ self.tandas.get(&self.activa)
+ }
+
+ /// Nombres en orden alfabético (el `BTreeMap` ya los ordena).
+ pub fn nombres(&self) -> Vec<String> {
+ self.tandas.keys().cloned().collect()
+ }
+
+ /// Fija la activa si existe.
+ pub fn set_activa(&mut self, nombre: &str) -> bool {
+ if self.tandas.contains_key(nombre) {
+ self.activa = nombre.to_string();
+ true
+ } else {
+ false
+ }
+ }
+
+ /// Agrega (o reemplaza) una tanda con nombre único a partir del suyo.
+ /// Devuelve el nombre con el que quedó.
+ pub fn agregar(&mut self, mut t: Tanda) -> String {
+ let nombre = self.nombre_unico(&t.nombre);
+ t.nombre = nombre.clone();
+ self.tandas.insert(nombre.clone(), t);
+ nombre
+ }
+
+ /// Duplica una tanda existente.
+ pub fn duplicar(&mut self, src: &str) -> Option<String> {
+ let mut t = self.tandas.get(src)?.clone();
+ t.nombre = format!("{src} copia");
+ Some(self.agregar(t))
+ }
+
+ /// Renombra. `false` si no existía o el destino ya estaba tomado.
+ pub fn renombrar(&mut self, de: &str, a: &str) -> bool {
+ let a = a.trim();
+ if a.is_empty() || self.tandas.contains_key(a) || !self.tandas.contains_key(de) {
+ return false;
+ }
+ if let Some(mut t) = self.tandas.remove(de) {
+ t.nombre = a.to_string();
+ self.tandas.insert(a.to_string(), t);
+ if self.activa == de {
+ self.activa = a.to_string();
+ }
+ return true;
+ }
+ false
+ }
+
+ /// Quita una tanda (nunca deja la biblioteca vacía ni sin activa).
+ pub fn quitar(&mut self, nombre: &str) {
+ self.tandas.remove(nombre);
+ self.sanear();
+ }
+
+ /// Repone un preset de fábrica a su estado original. `false` si el nombre
+ /// no es un preset.
+ pub fn restaurar_preset(&mut self, nombre: &str) -> bool {
+ match preset(nombre) {
+ Some(t) => {
+ self.tandas.insert(nombre.to_string(), t);
+ true
+ }
+ None => false,
+ }
+ }
+
+ fn nombre_unico(&self, hint: &str) -> String {
+ let base = if hint.trim().is_empty() { "tanda" } else { hint.trim() };
+ if !self.tandas.contains_key(base) {
+ return base.to_string();
+ }
+ (2..)
+ .map(|n| format!("{base} {n}"))
+ .find(|c| !self.tandas.contains_key(c))
+ .unwrap_or_else(|| base.to_string())
+ }
+
+ /// Escribe **sólo las tandas que difieren de las de fábrica**. Una que
+ /// editaste y devolviste a su valor original desaparece del archivo.
+ pub fn save(&self) -> std::io::Result<()> {
+ let Some(p) = Self::path() else {
+ return Err(std::io::Error::new(std::io::ErrorKind::NotFound, "sin HOME"));
+ };
+ if let Some(dir) = p.parent() {
+ std::fs::create_dir_all(dir)?;
+ }
+ let ser = |b: &Biblioteca| {
+ ron::ser::to_string_pretty(b, Self::pluma())
+ .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))
+ };
+ let completo = ser(self)?;
+ let base = ser(&Self::de_fabrica())?;
+ std::fs::write(&p, ron_ralo::ralo_con_mapas(&completo, &base, &["tandas"]))
+ }
+
+ /// `mtime` del archivo, para que el reproductor detecte que el panel lo
+ /// cambió sin necesitar IPC: mira el reloj del archivo y recarga.
+ pub fn mtime() -> Option<SystemTime> {
+ std::fs::metadata(Self::path()?).ok()?.modified().ok()
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::evento::Evento;
+
+ #[test]
+ fn la_activa_siempre_es_valida() {
+ let mut lib = Biblioteca::default();
+ lib.activa = "no existe".into();
+ lib.sanear();
+ assert!(lib.tandas.contains_key(&lib.activa), "activa: {}", lib.activa);
+ }
+
+ #[test]
+ fn el_maestro_apagado_deja_todo_mudo() {
+ let mut lib = Biblioteca::default();
+ lib.tandas.insert(PRESET_DEFECTO.into(), Tanda::default());
+ lib.habilitado = false;
+ let t = lib.tanda_efectiva();
+ assert_eq!(t.pico_objetivo(Evento::Notificacion), 0.0);
+ }
+
+ #[test]
+ fn el_volumen_maestro_escala_pero_no_sube() {
+ let mut lib = Biblioteca::default();
+ lib.tandas.insert(PRESET_DEFECTO.into(), Tanda::default());
+ lib.volumen = 0.5;
+ let base = Tanda::default().pico_objetivo(Evento::Notificacion);
+ let esc = lib.tanda_efectiva().pico_objetivo(Evento::Notificacion);
+ assert!((esc - base * 0.5).abs() < 1e-6, "{esc} vs {base}");
+ lib.volumen = 4.0;
+ lib.sanear();
+ assert_eq!(lib.volumen, 1.0);
+ }
+
+ #[test]
+ fn agregar_no_pisa_y_renombrar_sigue_a_la_activa() {
+ let mut lib = Biblioteca::default();
+ lib.tandas.insert("cámara".into(), Tanda::default());
+ let n = lib.agregar(Tanda::desde_paleta("cámara", Default::default()));
+ assert_eq!(n, "cámara 2");
+ lib.set_activa("cámara 2");
+ assert!(lib.renombrar("cámara 2", "mía"));
+ assert_eq!(lib.activa, "mía");
+ assert_eq!(lib.tandas.get("mía").map(|t| t.nombre.as_str()), Some("mía"));
+ }
+
+ #[test]
+ fn quitar_nunca_deja_la_biblioteca_vacia() {
+ let mut lib = Biblioteca::default();
+ lib.tandas.insert("única".into(), Tanda::default());
+ lib.activa = "única".into();
+ lib.quitar("única");
+ assert!(!lib.tandas.is_empty());
+ assert!(lib.tandas.contains_key(&lib.activa));
+ }
+}
diff --git a/02_ruway/takiy/takiy-tanda/src/bin/tanda.rs b/02_ruway/takiy/takiy-tanda/src/bin/tanda.rs
new file mode 100644
index 0000000..a24e389
--- /dev/null
+++ b/02_ruway/takiy/takiy-tanda/src/bin/tanda.rs
@@ -0,0 +1,192 @@
+//! `tanda` — la boca de línea de comandos de los avisos del escritorio.
+//!
+//! El shell (pata) toca solo lo que ve en su tick; todo lo demás —una captura
+//! de pantalla, un script que terminó, el bloqueo de sesión— entra por acá:
+//!
+//! ```bash
+//! tanda play captura # toca un evento con la tanda activa
+//! tanda list # los eventos y las tandas disponibles
+//! tanda new "campanas lejanas" # crea una tanda desde una frase y la activa
+//! tanda wav error /tmp/error.wav # rinde un evento a WAV (sin tocar el device)
+//! ```
+//!
+//! Los verbos van en inglés y los sustantivos del dominio en su idioma (Regla
+//! 7.bis): `tanda play notificacion`. Pensado para colgarse de un atajo de
+//! mirada (`captura de pantalla && tanda play captura`) o del final de un
+//! comando largo (`make && tanda play completado || tanda play error`). Abre el
+//! dispositivo de audio, toca y sale:
+//! para avisos frecuentes, el camino es el vigía del shell, no un proceso nuevo.
+
+use takiy_synth::write_wav;
+use takiy_tanda::{cosecha, desde_prosa, render, sonar, Biblioteca, Evento, Muestras, EVENTOS};
+
+const SR: u32 = 48_000;
+
+fn main() {
+ let args: Vec<String> = std::env::args().skip(1).collect();
+ let cmd = args.first().map(String::as_str).unwrap_or("ayuda");
+ match cmd {
+ "play" => tocar(args.get(1).map(String::as_str)),
+ "list" => listar(),
+ "new" => generar(&args[1..]),
+ "wav" => wav(args.get(1).map(String::as_str), args.get(2).map(String::as_str)),
+ "harvest" => cosechar(args.get(1).map(String::as_str)),
+ _ => ayuda(),
+ }
+}
+
+fn ayuda() {
+ println!(
+ "tanda — avisos sonoros del escritorio\n\
+ \n\
+ tanda play <evento> toca un evento con la tanda activa\n\
+ tanda list eventos y tandas disponibles\n\
+ tanda new \"<descripción>\" crea una tanda desde una frase y la activa\n\
+ tanda wav <evento> <ruta> rinde un evento a WAV\n\
+ tanda harvest [tanda] graba la tanda con instrumentos reales (SoundFont)\n\
+ \n\
+ config: {}",
+ Biblioteca::path()
+ .map(|p| p.display().to_string())
+ .unwrap_or_else(|| "(sin HOME)".into())
+ );
+}
+
+fn listar() {
+ let lib = Biblioteca::cargar();
+ println!("tandas ({}):", lib.tandas.len());
+ for (n, t) in &lib.tandas {
+ let marca = if *n == lib.activa { "●" } else { " " };
+ let cosechada = if cosecha::al_dia(n) { " · grabada" } else { "" };
+ println!(" {marca} {n:<20} {}{cosechada}", t.resumen());
+ }
+ let t = lib.tanda_efectiva();
+ println!(
+ "\nmaestro: {} · volumen {:.2}\n\neventos:",
+ if lib.habilitado { "encendido" } else { "apagado" },
+ lib.volumen
+ );
+ for e in EVENTOS {
+ println!(
+ " {} {:<16} {}",
+ if t.activo(e) { "♪" } else { "·" },
+ e.id(),
+ e.label()
+ );
+ }
+}
+
+/// Cosecha: carga el SoundFont UNA vez, toca los 18 avisos y los deja en disco.
+/// A partir de ahí el escritorio suena a instrumentos reales sin volver a
+/// tocar el SoundFont ni gastar la RAM que ocupa.
+fn cosechar(nombre: Option<&str>) {
+ let lib = Biblioteca::load_or_seed();
+ let cual = nombre.unwrap_or(&lib.activa).to_string();
+ let Some(tanda) = lib.tandas.get(&cual) else {
+ eprintln!("no hay ninguna tanda «{cual}». `tanda list` las muestra.");
+ std::process::exit(2);
+ };
+ let sr = 48_000;
+ let Some(cargado) = Muestras::del_sistema(sr) else {
+ eprintln!(
+ "no encontré ningún SoundFont.\n\
+ Instalá uno libre (en Arch: `soundfont-fluid`) o apuntá TAKIY_TANDA_SF2\n\
+ a un .sf2 — por ejemplo MuseScore_General.sf2 (MIT):\n\
+ https://ftp.osuosl.org/pub/musescore/soundfont/MuseScore_General/"
+ );
+ std::process::exit(1);
+ };
+ let m = match cargado {
+ Ok(m) => m,
+ Err(e) => {
+ eprintln!("{e}");
+ std::process::exit(1);
+ }
+ };
+ println!("instrumentos: {}", m.ruta().display());
+ match cosecha::cosechar(tanda, |t, ev| m.render(t, ev)) {
+ Ok(n) => {
+ let dir = cosecha::carpeta(&tanda.nombre)
+ .map(|p| p.display().to_string())
+ .unwrap_or_default();
+ println!("«{}»: {n} avisos grabados en {dir}", tanda.nombre);
+ println!("el escritorio ya los usa; `tanda play bienvenida` para oírlo.");
+ }
+ Err(e) => {
+ eprintln!("no pude escribir la cosecha: {e}");
+ std::process::exit(1);
+ }
+ }
+}
+
+fn tocar(evento: Option<&str>) {
+ let Some(ev) = evento.and_then(Evento::from_id) else {
+ eprintln!("evento desconocido. `tanda list` los muestra todos.");
+ std::process::exit(2);
+ };
+ let lib = Biblioteca::cargar();
+ let tanda = lib.tanda_efectiva();
+ if tanda.pico_objetivo(ev) <= 0.0 {
+ // Silencio deliberado no es un error: el usuario apagó ese aviso.
+ return;
+ }
+ let player = match takiy_playback::Player::open() {
+ Ok(p) => p,
+ Err(e) => {
+ eprintln!("sin salida de audio: {e}");
+ std::process::exit(1);
+ }
+ };
+ let (buf, origen) = sonar(&tanda, ev, player.sample_rate());
+ let dur = buf.duration_seconds();
+ if std::env::var("TANDA_VERBOSO").is_ok() {
+ eprintln!("{ev:?} desde {origen:?} · {dur:.2} s");
+ }
+ player.play(buf);
+ // El proceso tiene que vivir lo que dura el sonido: al salir se cierra el
+ // stream y el aviso se corta a la mitad.
+ std::thread::sleep(std::time::Duration::from_secs_f32(dur + 0.05));
+}
+
+fn generar(args: &[String]) {
+ let texto = args.join(" ");
+ if texto.trim().is_empty() {
+ eprintln!("falta la descripción: tanda new \"campanas lejanas y suaves\"");
+ std::process::exit(2);
+ }
+ let mut lib = Biblioteca::load_or_seed();
+ let t = desde_prosa(&texto, 0);
+ let resumen = t.resumen();
+ let nombre = lib.agregar(t);
+ lib.set_activa(&nombre);
+ match lib.save() {
+ Ok(()) => println!("tanda «{nombre}» — {resumen}\nactivada. `tanda play bienvenida` para oírla."),
+ Err(e) => {
+ eprintln!("no pude guardar: {e}");
+ std::process::exit(1);
+ }
+ }
+}
+
+fn wav(evento: Option<&str>, ruta: Option<&str>) {
+ let (Some(ev), Some(ruta)) = (evento.and_then(Evento::from_id), ruta) else {
+ eprintln!("uso: tanda wav <evento> <ruta.wav>");
+ std::process::exit(2);
+ };
+ let lib = Biblioteca::cargar();
+ let mut t = lib.tanda_efectiva();
+ t.set_activo(ev, true); // rendir a archivo es explícito: suena aunque esté apagado
+ let buf = render(&t, ev, SR);
+ match write_wav(&buf, ruta) {
+ Ok(()) => println!(
+ "{ruta}: {:.3} s · pico {:.4} · {} Hz",
+ buf.duration_seconds(),
+ buf.peak(),
+ SR
+ ),
+ Err(e) => {
+ eprintln!("no pude escribir {ruta}: {e}");
+ std::process::exit(1);
+ }
+ }
+}
diff --git a/02_ruway/takiy/takiy-tanda/src/cosecha.rs b/02_ruway/takiy/takiy-tanda/src/cosecha.rs
new file mode 100644
index 0000000..558719e
--- /dev/null
+++ b/02_ruway/takiy/takiy-tanda/src/cosecha.rs
@@ -0,0 +1,251 @@
+//! La **cosecha**: los 18 avisos ya tocados, en disco.
+//!
+//! Un SoundFont son cientos de megas de grabaciones y hay que cargarlo entero
+//! en memoria para tocar una nota. Eso está bien en una herramienta que corre
+//! una vez; está mal en el shell del escritorio, que vive todo el día en tu RAM
+//! para hacer sonar un *tin* cada tanto.
+//!
+//! Así que se separa en dos momentos. **Cosechar** (una vez, o cuando cambia la
+//! tanda) carga el SoundFont, toca los 18 avisos y los deja como WAV en
+//! `~/.local/share/takiy-tanda/<tanda>/`. **Sonar** (todo el día) sólo lee esos
+//! archivos: sin SoundFont, sin rustysynth, sin 215 MB. Si la cosecha no está,
+//! el escritorio sintetiza como siempre — nunca se queda mudo por falta de un
+//! archivo ajeno.
+//!
+//! Es el mismo criterio de todo el repo con los formatos ajenos: entran por un
+//! puente, se convierten a algo nuestro, y las apps trabajan sobre lo nuestro.
+
+use std::io::{self, Read};
+use std::path::PathBuf;
+
+use takiy_synth::{write_wav, AudioBuffer};
+
+use crate::evento::{Evento, EVENTOS};
+use crate::tanda::Tanda;
+
+/// `~/.local/share/takiy-tanda`, o lo que diga `TAKIY_TANDA_HARVEST`.
+pub fn raiz() -> Option<PathBuf> {
+ if let Ok(p) = std::env::var("TAKIY_TANDA_HARVEST") {
+ if !p.is_empty() {
+ return Some(PathBuf::from(p));
+ }
+ }
+ let base = std::env::var("XDG_DATA_HOME")
+ .ok()
+ .filter(|s| !s.is_empty())
+ .map(PathBuf::from)
+ .or_else(|| {
+ std::env::var("HOME")
+ .ok()
+ .map(|h| PathBuf::from(h).join(".local").join("share"))
+ })?;
+ Some(base.join("takiy-tanda"))
+}
+
+/// La carpeta de una tanda. El nombre se sanea: es un nombre de usuario y
+/// termina siendo una ruta.
+pub fn carpeta(nombre_tanda: &str) -> Option<PathBuf> {
+ let limpio: String = nombre_tanda
+ .chars()
+ .map(|c| if c.is_alphanumeric() || c == '-' || c == '_' { c } else { '_' })
+ .collect();
+ let limpio = if limpio.trim_matches('_').is_empty() { "tanda".to_string() } else { limpio };
+ Some(raiz()?.join(limpio))
+}
+
+/// El WAV de un evento de una tanda, si está cosechado.
+pub fn archivo(nombre_tanda: &str, evento: Evento) -> Option<PathBuf> {
+ let p = carpeta(nombre_tanda)?.join(format!("{}.wav", evento.id()));
+ p.is_file().then_some(p)
+}
+
+/// Lee un aviso cosechado. `None` si no está o el archivo no se entiende.
+pub fn leer(nombre_tanda: &str, evento: Evento) -> Option<AudioBuffer> {
+ let ruta = archivo(nombre_tanda, evento)?;
+ let bytes = std::fs::read(ruta).ok()?;
+ leer_wav(&bytes)
+}
+
+/// Escribe la cosecha completa de una tanda. Devuelve cuántos avisos escribió.
+///
+/// `rendir` es quien toca: normalmente el render por muestras
+/// ([`crate::muestras::Muestras::render`]), pero puede ser cualquiera — así se
+/// puede cosechar también la síntesis propia (útil para que el shell no gaste
+/// CPU en cada aviso de una máquina chica).
+pub fn cosechar<F>(tanda: &Tanda, mut rendir: F) -> io::Result<usize>
+where
+ F: FnMut(&Tanda, Evento) -> AudioBuffer,
+{
+ let Some(dir) = carpeta(&tanda.nombre) else {
+ return Err(io::Error::new(io::ErrorKind::NotFound, "sin HOME"));
+ };
+ std::fs::create_dir_all(&dir)?;
+ let mut n = 0;
+ for ev in EVENTOS {
+ // Se cosecha **todo**, incluso lo que la tanda trae apagado: encender un
+ // evento después no tiene por qué obligar a cosechar de nuevo.
+ let mut t = tanda.clone();
+ t.set_activo(ev, true);
+ let buf = rendir(&t, ev);
+ if buf.samples.is_empty() {
+ continue;
+ }
+ write_wav(&buf, dir.join(format!("{}.wav", ev.id())))?;
+ n += 1;
+ }
+ Ok(n)
+}
+
+/// Borra la cosecha de una tanda (cuando cambió la paleta y quedó vieja).
+pub fn limpiar(nombre_tanda: &str) -> io::Result<()> {
+ if let Some(dir) = carpeta(nombre_tanda) {
+ if dir.is_dir() {
+ std::fs::remove_dir_all(dir)?;
+ }
+ }
+ Ok(())
+}
+
+/// `true` si la cosecha de esta tanda existe y es **más nueva** que la config.
+/// Una cosecha vieja suena a la tanda de antes, que es peor que no tener
+/// ninguna: el usuario mueve una perilla y no pasa nada.
+pub fn al_dia(nombre_tanda: &str) -> bool {
+ let Some(dir) = carpeta(nombre_tanda) else {
+ return false;
+ };
+ let Ok(meta) = std::fs::metadata(dir.join("bienvenida.wav")) else {
+ return false;
+ };
+ let Ok(cosecha) = meta.modified() else {
+ return false;
+ };
+ match crate::biblioteca::Biblioteca::mtime() {
+ Some(config) => cosecha >= config,
+ // Sin config en disco no hay con qué compararla: sirve.
+ None => true,
+ }
+}
+
+/// Lector WAV mínimo: PCM entero de 16 bits o flotante de 32, el subconjunto
+/// que escribe [`takiy_synth::write_wav`]. Sin dependencias, como el escritor.
+fn leer_wav(bytes: &[u8]) -> Option<AudioBuffer> {
+ if bytes.len() < 44 || &bytes[0..4] != b"RIFF" || &bytes[8..12] != b"WAVE" {
+ return None;
+ }
+ let u16le = |b: &[u8]| u16::from_le_bytes([b[0], b[1]]);
+ let u32le = |b: &[u8]| u32::from_le_bytes([b[0], b[1], b[2], b[3]]);
+
+ let mut pos = 12;
+ let (mut formato, mut canales, mut sr, mut bits) = (1u16, 1u16, 44_100u32, 16u16);
+ let mut datos: Option<&[u8]> = None;
+ // Los chunks van encadenados: se recorren hasta encontrar `fmt ` y `data`.
+ while pos + 8 <= bytes.len() {
+ let id = &bytes[pos..pos + 4];
+ let largo = u32le(&bytes[pos + 4..pos + 8]) as usize;
+ let cuerpo_ini = pos + 8;
+ let cuerpo_fin = cuerpo_ini.saturating_add(largo).min(bytes.len());
+ match id {
+ b"fmt " if largo >= 16 => {
+ let c = &bytes[cuerpo_ini..cuerpo_fin];
+ formato = u16le(&c[0..2]);
+ canales = u16le(&c[2..4]);
+ sr = u32le(&c[4..8]);
+ bits = u16le(&c[14..16]);
+ }
+ b"data" => datos = Some(&bytes[cuerpo_ini..cuerpo_fin]),
+ _ => {}
+ }
+ // Los chunks se alinean a 2 bytes.
+ pos = cuerpo_ini + largo + (largo & 1);
+ }
+
+ let datos = datos?;
+ let samples: Vec<f32> = match (formato, bits) {
+ (1, 16) => datos
+ .chunks_exact(2)
+ .map(|b| i16::from_le_bytes([b[0], b[1]]) as f32 / 32_768.0)
+ .collect(),
+ (3, 32) => datos
+ .chunks_exact(4)
+ .map(|b| f32::from_le_bytes([b[0], b[1], b[2], b[3]]))
+ .collect(),
+ _ => return None,
+ };
+ Some(AudioBuffer {
+ sample_rate: sr,
+ channels: canales.max(1),
+ samples,
+ })
+}
+
+/// Lee un WAV de cualquier `Read` (para tests y para tuberías).
+pub fn leer_wav_de<R: Read>(mut r: R) -> Option<AudioBuffer> {
+ let mut bytes = Vec::new();
+ r.read_to_end(&mut bytes).ok()?;
+ leer_wav(&bytes)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn el_wav_da_la_vuelta_completa() {
+ // Lo que escribe takiy-synth lo tiene que poder leer esta caja: si no,
+ // la cosecha es un formato de una sola dirección.
+ let original = AudioBuffer::from_mono(48_000, vec![0.0, 0.5, -0.5, 0.25, -1.0, 1.0]);
+ let mut bytes = Vec::new();
+ takiy_synth::write_wav_to(&original, &mut bytes).unwrap();
+ let vuelta = leer_wav(&bytes).expect("el WAV recién escrito tiene que leerse");
+ assert_eq!(vuelta.sample_rate, 48_000);
+ assert_eq!(vuelta.channels, 1);
+ assert_eq!(vuelta.samples.len(), original.samples.len());
+ for (a, b) in original.samples.iter().zip(&vuelta.samples) {
+ // 16 bits: el error de cuantización es de un LSB.
+ assert!((a - b).abs() < 1e-4, "{a} vs {b}");
+ }
+ }
+
+ #[test]
+ fn un_wav_roto_no_rompe_nada() {
+ assert!(leer_wav(b"").is_none());
+ assert!(leer_wav(b"RIFFxxxxWAVEnada").is_none());
+ assert!(leer_wav(&[0u8; 200]).is_none());
+ }
+
+ #[test]
+ fn cosechar_y_volver_a_leer_da_el_mismo_aviso() {
+ // El circuito completo, con la síntesis propia haciendo de instrumento
+ // (no hace falta un SoundFont para probar el mecanismo).
+ let dir = std::env::temp_dir().join(format!("tanda-test-{}", std::process::id()));
+ std::env::set_var("TAKIY_TANDA_HARVEST", &dir);
+ let mut t = Tanda::default();
+ t.nombre = "prueba cosecha".into();
+
+ let n = cosechar(&t, |t, ev| crate::render::render(t, ev, 48_000)).unwrap();
+ assert_eq!(n, EVENTOS.len());
+
+ let leido = leer(&t.nombre, Evento::Bienvenida).expect("tiene que estar en disco");
+ let vivo = crate::render::render(&t, Evento::Bienvenida, 48_000);
+ assert_eq!(leido.sample_rate, vivo.sample_rate);
+ assert_eq!(leido.samples.len(), vivo.samples.len());
+ assert!((leido.peak() - vivo.peak()).abs() < 1e-3, "el nivel no sobrevivió el WAV");
+
+ // Un evento apagado también se cosecha: encenderlo después no puede
+ // obligar a cosechar de nuevo.
+ assert!(leer(&t.nombre, Evento::Volumen).is_some());
+
+ limpiar(&t.nombre).unwrap();
+ assert!(archivo(&t.nombre, Evento::Bienvenida).is_none());
+ std::env::remove_var("TAKIY_TANDA_HARVEST");
+ }
+
+ #[test]
+ fn el_nombre_de_la_tanda_no_se_escapa_de_su_carpeta() {
+ // El nombre lo escribe el usuario y termina siendo una ruta.
+ let p = carpeta("../../etc/passwd").unwrap();
+ assert!(!p.to_string_lossy().contains(".."), "{}", p.display());
+ let p2 = carpeta("mi tanda/rara").unwrap();
+ assert_eq!(p2.file_name().unwrap(), "mi_tanda_rara");
+ }
+}
diff --git a/02_ruway/takiy/takiy-tanda/src/evento.rs b/02_ruway/takiy/takiy-tanda/src/evento.rs
new file mode 100644
index 0000000..7daa50a
--- /dev/null
+++ b/02_ruway/takiy/takiy-tanda/src/evento.rs
@@ -0,0 +1,501 @@
+//! Los **eventos** del escritorio que merecen un sonido, y el *gesto* musical
+//! que le toca a cada uno.
+//!
+//! La lista es corta a propósito: un escritorio que suena por todo es un
+//! escritorio que se apaga. Cada evento trae su **gesto** —una o tres notas
+//! sobre la escala de la paleta— y su **peso** relativo, para que los avisos
+//! de fondo (una ventana que abre) vivan 12 dB por debajo de los que piden
+//! atención (un error).
+
+use serde::{Deserialize, Serialize};
+
+use crate::paleta::Voz;
+
+/// Un evento del escritorio con voz propia.
+///
+/// El orden de la enum es el orden en que se presenta en el panel: primero lo
+/// que el usuario oye todo el día, después lo excepcional.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
+pub enum Evento {
+ /// Llegó una notificación (willay).
+ Notificacion,
+ /// Llegó un mensaje de una persona (suilad/paloma).
+ Mensaje,
+ /// Una tarea larga terminó bien.
+ Completado,
+ /// Algo falló.
+ Error,
+ /// Algo pide cuidado pero no falló.
+ Advertencia,
+ /// Se abrió una ventana.
+ VentanaAbre,
+ /// Se cerró una ventana.
+ VentanaCierra,
+ /// Cambio de escritorio / workspace.
+ Escritorio,
+ /// Se tomó una captura de pantalla.
+ Captura,
+ /// Se conectó un dispositivo (USB, monitor, auriculares).
+ Conecta,
+ /// Se desconectó un dispositivo.
+ Desconecta,
+ /// La sesión se bloqueó.
+ Bloqueo,
+ /// La sesión se desbloqueó.
+ Desbloqueo,
+ /// Arranque de sesión — el saludo.
+ Bienvenida,
+ /// Cierre de sesión — la despedida.
+ Despedida,
+ /// La batería está en las últimas.
+ Bateria,
+ /// Sonó una
El parche sigue, pero se cortó acá: pedí el commit por git para verlo entero.