@@ -640,14 +640,6 @@ function normalizeVoiceIdToken(value) {
640640 return head ? String ( head ) . trim ( ) : "" ;
641641}
642642
643- function isLikelyMusicBodyLine ( line ) {
644- const trimmed = String ( line || "" ) . trim ( ) ;
645- if ( ! trimmed ) return false ;
646- if ( trimmed . startsWith ( "%" ) || / ^ % % / . test ( trimmed ) ) return false ;
647- if ( / ^ [ A - Z a - z ] [ A - Z a - z 0 - 9 _ - ] * \s * : / . test ( trimmed ) ) return false ;
648- return / [ A - G a - g x z Z ] / . test ( trimmed ) || / [ | ] / . test ( trimmed ) ;
649- }
650-
651643function parseMutedVoiceSetting ( value ) {
652644 const raw = String ( value || "" ) . trim ( ) ;
653645 if ( ! raw ) return [ ] ;
@@ -662,53 +654,82 @@ function parseMutedVoiceSetting(value) {
662654 return out ;
663655}
664656
665- function stripMutedVoicesForPlayback ( text , mutedVoices ) {
666- const muted = mutedVoices && typeof mutedVoices === "object"
667- ? Object . entries ( mutedVoices )
668- . filter ( ( [ , v ] ) => Boolean ( v ) )
669- . map ( ( [ k ] ) => normalizeVoiceIdToken ( k ) )
670- . filter ( Boolean )
671- : [ ] ;
672- if ( ! muted . length ) return text ;
673- if ( / \[ V \s * : / i. test ( text ) ) return String ( text || "" ) ;
674- const mutedSet = new Set ( muted ) ;
675- const lines = String ( text || "" ) . split ( / \r \n | \n | \r / ) ;
676- let inBody = false ;
677- let currentVoice = null ;
678- let firstVoiceId = null ;
679- const registerFirstVoice = ( id ) => {
680- if ( firstVoiceId ) return ;
681- firstVoiceId = String ( id || "" ) . trim ( ) || "1" ;
682- if ( mutedSet . has ( "1" ) ) mutedSet . add ( firstVoiceId ) ;
683- } ;
684- const out = [ ] ;
685- for ( const line of lines ) {
686- const trimmed = line . trim ( ) ;
687- if ( ! inBody && / ^ K : / . test ( trimmed ) ) {
688- inBody = true ;
689- out . push ( line ) ;
690- continue ;
691- }
692- if ( ! inBody ) {
693- out . push ( line ) ;
694- continue ;
695- }
696- const voiceLine = line . match ( / ^ \s * V \s * : \s * ( .* ) $ / i) ;
697- if ( voiceLine ) {
698- currentVoice = normalizeVoiceIdToken ( voiceLine [ 1 ] ) || "1" ;
699- registerFirstVoice ( currentVoice ) ;
700- if ( mutedSet . has ( currentVoice ) ) continue ;
701- out . push ( line ) ;
702- continue ;
657+ function resolveEffectiveMutedVoiceIds ( mutedVoiceIds , firstPlayableVoiceId ) {
658+ const ids = Array . isArray ( mutedVoiceIds ) ? mutedVoiceIds . map ( ( v ) => normalizeVoiceIdToken ( v ) ) . filter ( Boolean ) : [ ] ;
659+ if ( ! ids . length ) return [ ] ;
660+ const firstId = normalizeVoiceIdToken ( firstPlayableVoiceId ) ;
661+ const set = new Set ( ids ) ;
662+ if ( set . has ( "1" ) && firstId ) set . add ( firstId ) ;
663+ return Array . from ( set ) ;
664+ }
665+
666+ function getFirstPlayableVoiceIdFromTuneRoot ( firstSymbol ) {
667+ let s = firstSymbol || null ;
668+ let guard = 0 ;
669+ while ( s && s . ts_prev && guard < 200000 ) {
670+ s = s . ts_prev ;
671+ guard += 1 ;
672+ }
673+ guard = 0 ;
674+ while ( s && guard < 200000 ) {
675+ const pv = s . p_v || null ;
676+ const id = pv && pv . id != null ? String ( pv . id ) : "" ;
677+ const upper = id . toUpperCase ( ) ;
678+ if ( id && upper !== "_DRUM" && upper !== "_CHORD" && upper !== "_BEATS" ) return id ;
679+ s = s . ts_next ;
680+ guard += 1 ;
681+ }
682+ return "" ;
683+ }
684+
685+ function applyMutedVoicesToTuneRoot ( firstSymbol , mutedVoiceIds ) {
686+ const mutedSet = new Set ( Array . isArray ( mutedVoiceIds ) ? mutedVoiceIds . map ( ( v ) => String ( v ) ) : [ ] ) ;
687+ if ( ! firstSymbol || ! mutedSet . size ) return false ;
688+ let s = firstSymbol ;
689+ let guard = 0 ;
690+ while ( s && s . ts_prev && guard < 200000 ) {
691+ s = s . ts_prev ;
692+ guard += 1 ;
693+ }
694+ let changed = false ;
695+ guard = 0 ;
696+ while ( s && guard < 400000 ) {
697+ const pv = s . p_v || null ;
698+ const id = pv && pv . id != null ? String ( pv . id ) : "" ;
699+ if ( id && mutedSet . has ( id ) ) {
700+ s . noplay = true ;
701+ changed = true ;
702+ if ( Array . isArray ( s . notes ) ) {
703+ for ( const note of s . notes ) {
704+ if ( note && typeof note === "object" ) note . noplay = true ;
705+ }
706+ }
703707 }
704- if ( ! currentVoice && isLikelyMusicBodyLine ( line ) ) {
705- currentVoice = "1" ;
706- registerFirstVoice ( currentVoice ) ;
708+ s = s . ts_next ;
709+ guard += 1 ;
710+ }
711+ return changed ;
712+ }
713+
714+ function countPlayableByVoice ( firstSymbol ) {
715+ const out = new Map ( ) ;
716+ let s = firstSymbol ;
717+ let guard = 0 ;
718+ while ( s && s . ts_prev && guard < 200000 ) {
719+ s = s . ts_prev ;
720+ guard += 1 ;
721+ }
722+ guard = 0 ;
723+ while ( s && guard < 400000 ) {
724+ const playable = ! s . noplay && Number . isFinite ( s . dur ) && s . dur > 0 ;
725+ if ( playable ) {
726+ const id = ( s . p_v && s . p_v . id != null ) ? String ( s . p_v . id ) : "" ;
727+ if ( id ) out . set ( id , ( out . get ( id ) || 0 ) + 1 ) ;
707728 }
708- if ( currentVoice && mutedSet . has ( currentVoice ) ) continue ;
709- out . push ( line ) ;
729+ s = s . ts_next ;
730+ guard += 1 ;
710731 }
711- return out . join ( "\n" ) ;
732+ return out ;
712733}
713734
714735function shiftByNumberMap ( byNumber , renderOffset ) {
@@ -948,19 +969,31 @@ async function main() {
948969 process . exitCode = 1 ;
949970 }
950971
951- // Muted voices parsing / filtering regression tests.
972+ // Muted voices parsing / symbol-level muting regression tests.
952973 try {
953974 const ids = parseMutedVoiceSetting ( "2, 3 2" ) ;
954975 assert ( ids . length === 2 && ids [ 0 ] === "2" && ids [ 1 ] === "3" , "parseMutedVoiceSetting should dedupe" ) ;
955976
956- const muted23 = stripMutedVoicesForPlayback ( tuneText , { "2" : true , "3" : true } ) ;
957- assert ( / \n V : 1 \b / . test ( `\n${ muted23 } ` ) , "muted 2,3 should keep V:1" ) ;
958- assert ( ! / \n V : 2 \b / . test ( `\n${ muted23 } ` ) , "muted 2,3 should remove V:2" ) ;
959- assert ( ! / \n V : 3 \b / . test ( `\n${ muted23 } ` ) , "muted 2,3 should remove V:3" ) ;
960-
961- const muted1 = stripMutedVoicesForPlayback ( tuneText , { "1" : true } ) ;
962- assert ( ! / \n V : 1 \b / . test ( `\n${ muted1 } ` ) , "muted 1 should remove V:1" ) ;
963- assert ( / \n V : 2 \b / . test ( `\n${ muted1 } ` ) , "muted 1 should keep V:2" ) ;
977+ const baseRoot = parseTuneWithAbc2svg ( tuneText ) ;
978+ const baseCounts = countPlayableByVoice ( baseRoot ) ;
979+ assert ( ( baseCounts . get ( "1" ) || 0 ) > 0 , "fixture must contain playable V:1 symbols" ) ;
980+ assert ( ( baseCounts . get ( "2" ) || 0 ) > 0 , "fixture must contain playable V:2 symbols" ) ;
981+
982+ const mutedV1Root = parseTuneWithAbc2svg ( tuneText ) ;
983+ const firstId = getFirstPlayableVoiceIdFromTuneRoot ( mutedV1Root ) ;
984+ const effectiveMuteV1 = resolveEffectiveMutedVoiceIds ( [ "1" ] , firstId ) ;
985+ const changedV1 = applyMutedVoicesToTuneRoot ( mutedV1Root , effectiveMuteV1 ) ;
986+ assert ( changedV1 , "muted V:1 should modify tune symbols" ) ;
987+ const afterV1 = countPlayableByVoice ( mutedV1Root ) ;
988+ assert ( ( afterV1 . get ( "1" ) || 0 ) === 0 , "muted V:1 must silence voice 1" ) ;
989+ assert ( ( afterV1 . get ( "2" ) || 0 ) > 0 , "muted V:1 must keep voice 2 playable" ) ;
990+
991+ const mutedV2Root = parseTuneWithAbc2svg ( tuneText ) ;
992+ const changedV2 = applyMutedVoicesToTuneRoot ( mutedV2Root , [ "2" ] ) ;
993+ assert ( changedV2 , "muted V:2 should modify tune symbols" ) ;
994+ const afterV2 = countPlayableByVoice ( mutedV2Root ) ;
995+ assert ( ( afterV2 . get ( "2" ) || 0 ) === 0 , "muted V:2 must silence voice 2" ) ;
996+ assert ( ( afterV2 . get ( "1" ) || 0 ) > 0 , "muted V:2 must keep voice 1 playable" ) ;
964997
965998 const implicitVoiceText = [
966999 "X:1" ,
@@ -972,9 +1005,13 @@ async function main() {
9721005 "V:2" ,
9731006 "A2 B2 | c2 d2 |" ,
9741007 ] . join ( "\n" ) ;
975- const implicitMuted1 = stripMutedVoicesForPlayback ( implicitVoiceText , { "1" : true } ) ;
976- assert ( ! / D 2 E 2 / . test ( implicitMuted1 ) , "implicit V:1 content must be muted when voice 1 is muted" ) ;
977- assert ( / V : 2 / . test ( implicitMuted1 ) , "implicit V:1 mute should keep explicit V:2" ) ;
1008+ const implicitRoot = parseTuneWithAbc2svg ( implicitVoiceText ) ;
1009+ const implicitFirst = getFirstPlayableVoiceIdFromTuneRoot ( implicitRoot ) ;
1010+ const implicitEffective = resolveEffectiveMutedVoiceIds ( [ "1" ] , implicitFirst ) ;
1011+ assert ( implicitEffective . includes ( implicitFirst ) , "implicit/malformed V:1 should map to de-facto first voice" ) ;
1012+ applyMutedVoicesToTuneRoot ( implicitRoot , implicitEffective ) ;
1013+ const implicitAfter = countPlayableByVoice ( implicitRoot ) ;
1014+ assert ( ( implicitAfter . get ( "2" ) || 0 ) > 0 , "implicit V:1 mute should keep explicit V:2 playable" ) ;
9781015
9791016 const malformedVoiceText = [
9801017 "X:1" ,
@@ -987,12 +1024,16 @@ async function main() {
9871024 "V:2" ,
9881025 "A2 B2 | c2 d2 |" ,
9891026 ] . join ( "\n" ) ;
990- const malformedMuted1 = stripMutedVoicesForPlayback ( malformedVoiceText , { "1" : true } ) ;
991- assert ( ! / D 2 E 2 / . test ( malformedMuted1 ) , "malformed V: should still be treated as de-facto V:1" ) ;
992- assert ( / V : 2 / . test ( malformedMuted1 ) , "malformed V: mute should keep explicit V:2" ) ;
993- console . log ( "% PASS TEST 13: Muted voices (including implicit/malformed V:1) behave correctly" ) ;
1027+ const malformedRoot = parseTuneWithAbc2svg ( malformedVoiceText ) ;
1028+ const malformedFirst = getFirstPlayableVoiceIdFromTuneRoot ( malformedRoot ) ;
1029+ const malformedEffective = resolveEffectiveMutedVoiceIds ( [ "1" ] , malformedFirst ) ;
1030+ assert ( malformedEffective . includes ( malformedFirst ) , "malformed V: should still map mute 1 to de-facto first voice" ) ;
1031+ applyMutedVoicesToTuneRoot ( malformedRoot , malformedEffective ) ;
1032+ const malformedAfter = countPlayableByVoice ( malformedRoot ) ;
1033+ assert ( ( malformedAfter . get ( "2" ) || 0 ) > 0 , "malformed V: mute should keep explicit V:2 playable" ) ;
1034+ console . log ( "% PASS TEST 13: Muted voices (including V:1 and implicit/malformed V:1) behave correctly" ) ;
9941035 } catch ( e ) {
995- console . log ( "% FAIL TEST 13: Muted voices (including implicit/malformed V:1) behave correctly" ) ;
1036+ console . log ( "% FAIL TEST 13: Muted voices (including V:1 and implicit/malformed V:1) behave correctly" ) ;
9961037 String ( e && e . message ? e . message : e ) . split ( / \r \n | \n | \r / ) . forEach ( ( line ) => console . log ( `% ${ line } ` ) ) ;
9971038 process . exitCode = 1 ;
9981039 }
0 commit comments