@@ -34,6 +34,31 @@ const SLIDER_RANGE_UNITS = Object.freeze({
3434 volume : ""
3535} ) ;
3636
37+ const RECOMMENDED_ZONE_SPAN = Object . freeze ( {
38+ attackMs : 0.36 ,
39+ durationMs : 0.34 ,
40+ frequencyHz : 0.28 ,
41+ noiseAmount : 0.36 ,
42+ noiseDecayMs : 0.34 ,
43+ noiseFilterHz : 0.32 ,
44+ pitchSweepCents : 0.38 ,
45+ releaseMs : 0.34 ,
46+ volume : 0.3
47+ } ) ;
48+
49+ const STYLE_DESCRIPTIONS = Object . freeze ( {
50+ custom : "Full-range design mode for building a sound without a style preset." ,
51+ "pure-tone" : "Clean oscillator tones with little noise and broad pitch control." ,
52+ "noise-only" : "Transient noise bursts for impacts, hits, glitches, and static." ,
53+ "atari-style" : "Softer analog-style arcade tones with midrange sweeps." ,
54+ "classic-arcade" : "Punchy cabinet-era blips, zaps, and short mixed noise accents." ,
55+ "early-analog" : "Warm early synth effects with slower envelopes and wider motion." ,
56+ "namco-style" : "Bright melodic arcade pings with compact timing and clear pitch." ,
57+ "nintendo-style" : "Crisp console-style square tones with tight envelopes." ,
58+ "ttl-arcade" : "Harsh digital logic-style arcade sounds." ,
59+ "vector-arcade" : "Clean vector-display era synth tones."
60+ } ) ;
61+
3762const STYLE_CLAMPS = Object . freeze ( {
3863 "pure-tone" : Object . freeze ( {
3964 attackMs : Object . freeze ( { min : 0 , max : 120 } ) ,
@@ -292,6 +317,10 @@ function formatRangeNumber(soundKey, value) {
292317 return String ( Math . round ( value ) ) ;
293318}
294319
320+ function formatPercent ( value ) {
321+ return `${ Math . round ( value * 1000 ) / 10 } %` ;
322+ }
323+
295324export class SfxControlPanel {
296325 constructor ( {
297326 addButton,
@@ -314,6 +343,7 @@ export class SfxControlPanel {
314343 pitchSweepValue,
315344 releaseInput,
316345 releaseValue,
346+ styleDescription,
317347 styleProfileSelect,
318348 validationMessage,
319349 volumeInput,
@@ -341,6 +371,7 @@ export class SfxControlPanel {
341371 this . pitchSweepValue = pitchSweepValue ;
342372 this . releaseInput = releaseInput ;
343373 this . releaseValue = releaseValue ;
374+ this . styleDescription = styleDescription ;
344375 this . styleProfileSelect = styleProfileSelect ;
345376 this . validationMessage = validationMessage ;
346377 this . volumeInput = volumeInput ;
@@ -449,20 +480,23 @@ export class SfxControlPanel {
449480 this . releaseInput . value = String ( sound . releaseMs ) ;
450481 this . volumeInput . value = String ( sound . volume ) ;
451482 this . waveformSelect . value = sound . waveform ;
483+ this . syncStyleDescription ( ) ;
452484 this . syncOutputs ( ) ;
453485 }
454486
455487 applyStyleProfile ( ) {
456488 const styleKey = this . styleProfileSelect . value ;
457489 if ( styleKey === "custom" ) {
458490 this . applySliderLimits ( "custom" , true ) ;
491+ this . syncStyleDescription ( ) ;
459492 this . syncOutputs ( ) ;
460493 return true ;
461494 }
462495 const profile = STYLE_PROFILES [ styleKey ] ;
463496 if ( ! profile ) {
464497 this . styleProfileSelect . value = "custom" ;
465498 this . applySliderLimits ( "custom" , true ) ;
499+ this . syncStyleDescription ( ) ;
466500 return false ;
467501 }
468502 this . applySliderLimits ( styleKey , true ) ;
@@ -478,11 +512,13 @@ export class SfxControlPanel {
478512 this . releaseInput . value = String ( profile . releaseMs ) ;
479513 this . volumeInput . value = String ( profile . volume ) ;
480514 this . waveformSelect . value = profile . waveform ;
515+ this . syncStyleDescription ( ) ;
481516 this . syncOutputs ( ) ;
482517 return true ;
483518 }
484519
485520 syncOutputs ( ) {
521+ this . syncRecommendedZones ( ) ;
486522 this . attackValue . textContent = this . valueWithRange ( "attackMs" , `${ Math . round ( toNumber ( this . attackInput ) ) } ms` ) ;
487523 this . durationValue . textContent = this . valueWithRange ( "durationMs" , `${ Math . round ( toNumber ( this . durationInput ) ) } ms` ) ;
488524 this . frequencyValue . textContent = this . valueWithRange ( "frequencyHz" , `${ Math . round ( toNumber ( this . frequencyInput ) ) } Hz` ) ;
@@ -499,12 +535,73 @@ export class SfxControlPanel {
499535 }
500536
501537 rangeText ( soundKey ) {
502- const limits = this . activeSliderLimits [ soundKey ] ;
538+ return this . formatRangeText ( soundKey , this . activeSliderLimits [ soundKey ] ) ;
539+ }
540+
541+ formatRangeText ( soundKey , limits ) {
503542 const unit = SLIDER_RANGE_UNITS [ soundKey ] ;
504543 const range = `${ formatRangeNumber ( soundKey , limits . min ) } -${ formatRangeNumber ( soundKey , limits . max ) } ` ;
505544 return unit ? `${ range } ${ unit } ` : range ;
506545 }
507546
547+ syncStyleDescription ( ) {
548+ const styleKey = this . styleProfileSelect . value || "custom" ;
549+ this . styleDescription . textContent = STYLE_DESCRIPTIONS [ styleKey ] || STYLE_DESCRIPTIONS . custom ;
550+ }
551+
552+ syncRecommendedZones ( ) {
553+ SLIDER_INPUTS . forEach ( ( item ) => {
554+ const input = this [ item . inputProperty ] ;
555+ const zone = this . recommendedZoneFor ( item . soundKey ) ;
556+ const limits = this . activeSliderLimits [ item . soundKey ] ;
557+ const start = this . percentForValue ( limits , zone . min ) ;
558+ const end = this . percentForValue ( limits , zone . max ) ;
559+ input . style . setProperty ( "--audio-sfx-zone-start" , formatPercent ( start ) ) ;
560+ input . style . setProperty ( "--audio-sfx-zone-end" , formatPercent ( end ) ) ;
561+ input . dataset . recommendedZone = this . formatRangeText ( item . soundKey , zone ) ;
562+ input . title = `Recommended Zone: ${ input . dataset . recommendedZone } ` ;
563+ } ) ;
564+ }
565+
566+ recommendedZoneFor ( soundKey ) {
567+ const styleKey = this . styleProfileSelect . value || "custom" ;
568+ const limits = this . activeSliderLimits [ soundKey ] ;
569+ if ( styleKey === "custom" ) {
570+ return { min : limits . min , max : limits . max } ;
571+ }
572+ const profile = STYLE_PROFILES [ styleKey ] || { } ;
573+ const centerSource = Object . hasOwn ( profile , soundKey ) ? profile [ soundKey ] : DEFAULT_SOUND [ soundKey ] ;
574+ const center = this . clampNumber ( Number ( centerSource ) , limits ) ;
575+ const totalRange = Math . max ( 0 , limits . max - limits . min ) ;
576+ const span = Math . max (
577+ SLIDER_LIMITS [ soundKey ] . step ,
578+ totalRange * ( RECOMMENDED_ZONE_SPAN [ soundKey ] || 0.34 )
579+ ) ;
580+ const halfSpan = span / 2 ;
581+ let min = this . clampNumber ( center - halfSpan , limits ) ;
582+ let max = this . clampNumber ( center + halfSpan , limits ) ;
583+ if ( min === max ) {
584+ min = this . clampNumber ( center - SLIDER_LIMITS [ soundKey ] . step , limits ) ;
585+ max = this . clampNumber ( center + SLIDER_LIMITS [ soundKey ] . step , limits ) ;
586+ }
587+ return { min, max } ;
588+ }
589+
590+ percentForValue ( limits , value ) {
591+ const range = limits . max - limits . min ;
592+ if ( range <= 0 ) {
593+ return 0 ;
594+ }
595+ return ( this . clampNumber ( value , limits ) - limits . min ) / range ;
596+ }
597+
598+ clampNumber ( value , limits ) {
599+ if ( ! Number . isFinite ( value ) ) {
600+ return limits . min ;
601+ }
602+ return Math . min ( limits . max , Math . max ( limits . min , value ) ) ;
603+ }
604+
508605 validate ( ) {
509606 const name = this . nameInput . value . trim ( ) ;
510607 if ( ! name ) {
0 commit comments