@@ -30,6 +30,7 @@ import {
3030 resetScreensaverSwapMarkers ,
3131} from "utils/randomCycleBridge" ;
3232import { createReadbackCanvas , getReadbackContext } from "utils" ;
33+ import { AUTO_VIZ_DENSITY , buildAutoVizConnections } from "utils/autoViz" ;
3334import type { AudioVizConnection , AudioVizMetric , EntryAudioModulation , GlobalAudioVizModulation } from "utils/audioVizBridge" ;
3435import { getGlobalAudioVizModulation , getAudioVizMetricValueForMode , getAudioVizSnapshot as getChannelAudioVizSnapshot , resetAudioVizTempo , setActiveAudioVizChannel , setGlobalAudioVizModulation , subscribeAudioViz , tapDownbeat , updateAudioVizChannel } from "utils/audioVizBridge" ;
3536import { setupWebMCP } from "@src/webmcp" ;
@@ -299,228 +300,8 @@ const buildNormalizedMetricsDraft = (modulation: EntryAudioModulation | GlobalAu
299300 [ ...( modulation ?. normalizedMetrics ?? [ ] ) ] ;
300301
301302const meterStyle = ( value : number ) => ( { width : `${ Math . max ( 4 , Math . round ( value * 100 ) ) } %` } ) ;
302- const randomBetween = ( min : number , max : number ) => min + Math . random ( ) * ( max - min ) ;
303- const shuffleArray = < T , > ( items : T [ ] ) => {
304- const next = [ ...items ] ;
305- for ( let i = next . length - 1 ; i > 0 ; i -= 1 ) {
306- const j = Math . floor ( Math . random ( ) * ( i + 1 ) ) ;
307- [ next [ i ] , next [ j ] ] = [ next [ j ] , next [ i ] ] ;
308- }
309- return next ;
310- } ;
311- const AUTO_VIZ_METRIC_GROUPS : Record < AutoVizMode , AudioVizMetric [ ] > = {
312- balanced : [
313- "beatHold" , "beat" , "bassEnvelope" , "midEnvelope" , "trebleEnvelope" ,
314- "spectralCentroid" , "tempoPhase" , "barBeat" , "bandRatio" ,
315- "percussive" , "harmonic" , "peakDecay" ,
316- ] ,
317- punchy : [
318- "beat" , "beatHold" , "subKick" , "onset" , "percussive" , "peakDecay" ,
319- "bassEnvelope" , "pulse" , "zeroCrossing" , "barBeat" , "spectralFlux" ,
320- ] ,
321- flow : [
322- "tempoPhase" , "barPhase" , "barBeat" , "spectralCentroid" , "harmonic" ,
323- "midEnvelope" , "bassEnvelope" , "trebleEnvelope" , "bandRatio" ,
324- "stereoWidth" , "stereoBalance" , "beatConfidence" ,
325- ] ,
326- chaotic : [
327- "beat" , "onset" , "spectralFlux" , "percussive" , "roughness" , "zeroCrossing" ,
328- "pulse" , "stereoWidth" , "stereoBalance" , "spectralCentroid" ,
329- "beatHold" , "subKick" ,
330- ] ,
331- } ;
332- const AUTO_VIZ_DEFAULT_DENSITY = 0.2 ;
333- const AUTO_VIZ_DENSITY : Record < AutoVizMode , number > = {
334- balanced : AUTO_VIZ_DEFAULT_DENSITY ,
335- punchy : AUTO_VIZ_DEFAULT_DENSITY ,
336- flow : AUTO_VIZ_DEFAULT_DENSITY ,
337- chaotic : AUTO_VIZ_DEFAULT_DENSITY ,
338- } ;
339- const AUTO_VIZ_MIN_CONNECTIONS = 3 ;
340- const AUTO_VIZ_MAX_CONNECTIONS = 10 ;
341- const AUTO_VIZ_NORMALIZE_SKIP = new Set < AudioVizMetric > ( [
342- "bpm" , "tempoPhase" , "barPhase" , "barBeat" , "stereoBalance" , "beatConfidence" ,
343- ] ) ;
344- const AUTO_VIZ_WEIGHT_RANGES : Partial < Record < AudioVizMetric , [ number , number ] > > = {
345- bpm : [ 0.3 , 0.95 ] ,
346- tempoPhase : [ 0.15 , 0.5 ] ,
347- barPhase : [ 0.18 , 0.55 ] ,
348- barBeat : [ 0.25 , 0.75 ] ,
349- beat : [ 0.55 , 1.3 ] ,
350- beatHold : [ 0.45 , 1.1 ] ,
351- bassEnvelope : [ 0.4 , 1.05 ] ,
352- midEnvelope : [ 0.3 , 0.9 ] ,
353- trebleEnvelope : [ 0.3 , 0.9 ] ,
354- peakDecay : [ 0.3 , 0.9 ] ,
355- subKick : [ 0.4 , 1.15 ] ,
356- pulse : [ 0.3 , 0.9 ] ,
357- onset : [ 0.4 , 1.05 ] ,
358- spectralCentroid : [ 0.3 , 0.85 ] ,
359- spectralFlux : [ 0.4 , 1.0 ] ,
360- roughness : [ 0.3 , 0.85 ] ,
361- zeroCrossing : [ 0.3 , 0.85 ] ,
362- bandRatio : [ 0.3 , 0.8 ] ,
363- harmonic : [ 0.3 , 0.85 ] ,
364- percussive : [ 0.4 , 1.05 ] ,
365- stereoWidth : [ 0.35 , 0.95 ] ,
366- stereoBalance : [ 0.3 , 0.8 ] ,
367- beatConfidence : [ 0.2 , 0.65 ] ,
368- level : [ 0.3 , 0.9 ] ,
369- bass : [ 0.3 , 0.9 ] ,
370- mid : [ 0.3 , 0.85 ] ,
371- treble : [ 0.3 , 0.85 ] ,
372- } ;
373- const weightRangeFor = ( metric : AudioVizMetric ) : [ number , number ] =>
374- AUTO_VIZ_WEIGHT_RANGES [ metric ] ?? [ 0.3 , 0.95 ] ;
375- const TRANSIENT_PARAMS = [
376- "amount" , "mix" , "intensity" , "strength" , "threshold" , "glitch" , "noise" ,
377- "contrast" , "edge" , "detail" , "sharpen" , "poster" , "posterize" ,
378- "density" , "count" , "morph" , "iterations" , "spread" , "dust" , "grit" ,
379- ] ;
380- const HEAVY_PARAMS = [
381- "size" , "scale" , "radius" , "blur" , "smear" , "feedback" , "decay" ,
382- "persistence" , "block" , "pixel" , "distort" , "warp" , "offset" , "displace" ,
383- "line" , "scan" , "depth" , "rows" , "cols" , "grid" , "cell" , "tile" , "chunk" ,
384- ] ;
385- const TONE_PARAMS = [
386- "hue" , "color" , "palette" , "gamma" , "brightness" , "saturation" , "tone" ,
387- "warm" , "cool" , "channel" , "rgb" , "contrast" ,
388- "temperature" , "lightness" , "chroma" , "tint" , "shade" , "value" ,
389- ] ;
390- const FLOW_PARAMS = [
391- "phase" , "speed" , "angle" , "rotate" , "offset" , "scroll" , "drift" , "wave" ,
392- "wobble" , "frequency" , "motion" ,
393- "shift" , "time" , "step" , "cycle" , "sweep" ,
394- ] ;
395- const NOISE_PARAMS = [
396- "noise" , "glitch" , "detail" , "edge" , "grain" , "jitter" , "spark" , "rough" ,
397- "scratch" , "hash" , "fizz" , "speckle" ,
398- ] ;
399- const SCORE_DEFAULT = 2 ;
400- const scoreParamForMetric = ( metric : AudioVizMetric , optionName : string , label ?: string ) => {
401- const haystack = `${ optionName } ${ label || "" } ` . toLowerCase ( ) ;
402- const includesKeyword = ( keywords : string [ ] ) => keywords . some ( ( keyword ) => haystack . includes ( keyword ) ) ;
403- let score = SCORE_DEFAULT ;
404- if ( metric === "beat" || metric === "beatHold" || metric === "onset" || metric === "percussive" || metric === "pulse" || metric === "subKick" ) {
405- score += includesKeyword ( TRANSIENT_PARAMS ) ? 6 : 0 ;
406- score += includesKeyword ( HEAVY_PARAMS ) ? 2 : 0 ;
407- }
408- if ( metric === "bassEnvelope" || metric === "peakDecay" || metric === "bass" ) {
409- score += includesKeyword ( HEAVY_PARAMS ) ? 7 : 0 ;
410- score += includesKeyword ( TRANSIENT_PARAMS ) ? 2 : 0 ;
411- }
412- if ( metric === "spectralCentroid" || metric === "treble" || metric === "harmonic" || metric === "trebleEnvelope" || metric === "bandRatio" ) {
413- score += includesKeyword ( TONE_PARAMS ) ? 7 : 0 ;
414- score += includesKeyword ( TRANSIENT_PARAMS ) ? 1 : 0 ;
415- }
416- if ( metric === "tempoPhase" || metric === "bpm" || metric === "barPhase" || metric === "barBeat" ) {
417- score += includesKeyword ( FLOW_PARAMS ) ? 7 : 0 ;
418- score += includesKeyword ( HEAVY_PARAMS ) ? 1 : 0 ;
419- }
420- if ( metric === "spectralFlux" || metric === "roughness" || metric === "zeroCrossing" ) {
421- score += includesKeyword ( NOISE_PARAMS ) ? 7 : 0 ;
422- score += includesKeyword ( TRANSIENT_PARAMS ) ? 2 : 0 ;
423- }
424- if ( metric === "stereoWidth" || metric === "stereoBalance" ) {
425- score += includesKeyword ( FLOW_PARAMS ) ? 3 : 0 ;
426- score += includesKeyword ( TONE_PARAMS ) ? 2 : 0 ;
427- score += includesKeyword ( HEAVY_PARAMS ) ? 2 : 0 ;
428- }
429- if ( metric === "midEnvelope" || metric === "mid" ) {
430- score += includesKeyword ( TRANSIENT_PARAMS ) ? 3 : 0 ;
431- score += includesKeyword ( HEAVY_PARAMS ) ? 2 : 0 ;
432- score += includesKeyword ( TONE_PARAMS ) ? 2 : 0 ;
433- }
434- return score ;
435- } ;
436-
437- const pickMetricsForMode = (
438- mode : AutoVizMode ,
439- count : number ,
440- previous : AudioVizConnection [ ] | null ,
441- ) : AudioVizMetric [ ] => {
442- const pool = AUTO_VIZ_METRIC_GROUPS [ mode ] ;
443- const prevMetrics = new Set ( ( previous ?? [ ] ) . map ( ( c ) => c . metric ) ) ;
444- const shuffled = shuffleArray ( pool ) ;
445- const fresh = shuffled . filter ( ( m ) => ! prevMetrics . has ( m ) ) ;
446- const reused = shuffled . filter ( ( m ) => prevMetrics . has ( m ) ) ;
447- const ordered = [ ...fresh , ...reused ] ;
448- const slice = ordered . slice ( 0 , Math . min ( count , pool . length ) ) ;
449- if ( mode !== "flow" && ! slice . includes ( "beat" ) && ! slice . includes ( "beatHold" ) && slice . length > 0 ) {
450- const inject : AudioVizMetric = Math . random ( ) < 0.5 ? "beat" : "beatHold" ;
451- if ( ! slice . includes ( inject ) ) slice [ slice . length - 1 ] = inject ;
452- }
453- return slice ;
454- } ;
455-
456- const buildAutoVizConnections = (
457- mode : AutoVizMode ,
458- rangeOptions : Array < readonly [ string , AudioPatchTargetOption ] > ,
459- previous : AudioVizConnection [ ] | null = null ,
460- densityOverride : number | null = null ,
461- ) : { connections : AudioVizConnection [ ] ; normalizedMetrics : AudioVizMetric [ ] } => {
462- if ( rangeOptions . length === 0 ) {
463- return { connections : [ ] , normalizedMetrics : [ ] } ;
464- }
465-
466- const density = densityOverride != null && densityOverride > 0
467- ? densityOverride
468- : AUTO_VIZ_DENSITY [ mode ] ;
469- const desired = Math . round ( rangeOptions . length * density ) ;
470- const clamped = Math . max (
471- AUTO_VIZ_MIN_CONNECTIONS ,
472- Math . min ( AUTO_VIZ_MAX_CONNECTIONS , Math . min ( desired , rangeOptions . length ) ) ,
473- ) ;
474- const chosenMetrics = pickMetricsForMode ( mode , clamped , previous ) ;
475-
476- const previousTargets = new Set ( ( previous ?? [ ] ) . map ( ( c ) => c . target ) ) ;
477- const availableTargets = new Set ( rangeOptions . map ( ( [ optionName ] ) => optionName ) ) ;
478- const connections : AudioVizConnection [ ] = [ ] ;
479-
480- for ( const metric of chosenMetrics ) {
481- const ranked = shuffleArray ( rangeOptions )
482- . filter ( ( [ key ] ) => availableTargets . has ( key ) )
483- . map ( ( entry ) => {
484- const score = scoreParamForMetric ( metric , entry [ 1 ] . optionName || entry [ 0 ] , entry [ 1 ] . label ) ;
485- const novelty = previousTargets . has ( entry [ 0 ] ) ? 0 : 1.5 ;
486- const jitter = Math . random ( ) * 1.2 ;
487- return { entry, combined : score + novelty + jitter } ;
488- } )
489- . sort ( ( a , b ) => b . combined - a . combined ) ;
490- const winner = ranked [ 0 ] ?. entry ;
491- if ( ! winner ) continue ;
492- const [ target ] = winner ;
493- availableTargets . delete ( target ) ;
494- const [ lo , hi ] = weightRangeFor ( metric ) ;
495- const baseWeight = randomBetween ( lo , hi ) ;
496- const sign = mode === "chaotic"
497- ? ( Math . random ( ) < 0.4 ? - 1 : 1 )
498- : ( Math . random ( ) < 0.14 ? - 1 : 1 ) ;
499- connections . push ( {
500- metric,
501- target,
502- weight : Math . max ( AUDIO_METRIC_WEIGHT_MIN , Math . min ( AUDIO_METRIC_WEIGHT_MAX , baseWeight * sign ) ) ,
503- } ) ;
504- }
505-
506- if ( mode === "chaotic" && connections . length > 1 && connections . every ( ( c ) => c . weight >= 0 ) ) {
507- const flip = Math . floor ( Math . random ( ) * connections . length ) ;
508- connections [ flip ] . weight = - connections [ flip ] . weight ;
509- }
510-
511- if ( connections . length === 0 && rangeOptions . length > 0 ) {
512- connections . push ( {
513- metric : "beatHold" ,
514- target : rangeOptions [ 0 ] [ 0 ] ,
515- weight : 0.6 ,
516- } ) ;
517- }
518-
519- const normalizedMetrics = connections
520- . map ( ( connection ) => connection . metric )
521- . filter ( ( metric , index , all ) => ! AUTO_VIZ_NORMALIZE_SKIP . has ( metric ) && all . indexOf ( metric ) === index ) ;
522- return { connections, normalizedMetrics } ;
523- } ;
303+ // Auto-viz logic lives in src/utils/autoViz.ts so it can be unit tested
304+ // without spinning up the full App component.
524305const formatAudioMetricReadout = (
525306 snapshot : ReturnType < typeof getChannelAudioVizSnapshot > ,
526307 metric : AudioVizMetric ,
0 commit comments