📄 scoreBuilder.js
/home/palash/git/site/node_modules/new_site/src/components/tab-viewer/scoreBuilder.js
Language: js • Lines: 397
import { useMemo } from "react";
import { TUNING, NOTE_NAMES, RHYTHM_BEAT_VALUES } from "./audio";
import { parsePitchProperties } from "./guitaleleViewerUtils";

export function useBuildScoreLayout(scoreData, slotWidth, measuresPerRow) {
    return useMemo(() => {
        // 1. Updated guard clause to check for the 'measures' array
        if (!scoreData || !scoreData.measures) return null;

        const paddingX = 130;
        const lineSpacing = 18;
        const trebleTopY = 100;
        const bassTopY = trebleTopY + 120;
        const tabTopY = bassTopY + 120;
        const rhythmTopY = tabTopY + 180;

        const timeSigTop = scoreData.timeSignature?.split("/")[0] || "4";
        const timeSigBottom = scoreData.timeSignature?.split("/")[1] || "4";
        const numerator = parseInt(timeSigTop, 10);
        const denominator = parseInt(timeSigBottom, 10);
        const beatsPerMeasure = numerator * (4 / denominator);

        const SLOT_WIDTH = slotWidth;
        const MEASURE_PADDING = 25;

        let processedEvents = [];
        let absoluteMeasureStartBeat = 0;
        let globalEventIndex = 0; // Tracks the ID for playback/hover highlighting across measures


        // Loop through isolated measure containers
        scoreData.measures.forEach(measure => {
            const mNum = measure.measureNumber;
            let localCursors = { 1: 0, 2: 0 };

            // We'll use a temporary map for this measure to merge notes sharing the same startBeat and voice
            const beatMergeMap = {};

            // --- METRONOME LINE GENERATION ---
            for (let click = 0; click < numerator; click++) {
                const clickBeatSpacing = 4 / denominator; 
                const clickStartBeat = absoluteMeasureStartBeat + (click * clickBeatSpacing);
                const clickMergeKey = `0_${clickStartBeat}`;

                beatMergeMap[clickMergeKey] = {
                    globalIndex: null, // Assigned sequentially during final flattening
                    measureNumber: mNum,
                    voice: 0,
                    startBeat: clickStartBeat,
                    beatValue: clickBeatSpacing,
                    isRest: false,
                    isMetronomeTick: true,
                    isDownbeat: click === 0, // Useful for adding visual accent to beat 1
                    rhythm: ":", // Default rhythm label for metronome ticks
                    pitches: [],
                    descriptions: ["Metronome"]
                };
            }

            measure.notes.forEach(event => {
                let detectedRhythm = event.rhythm;
                let pitches = event.pitches || [];

                if (!event.pitches &&
                    event.fret !== undefined &&
                    event.string !== undefined) {
                    pitches = [{ fret: event.fret, string: event.string }];
                }

                const uniqueStringsMap = {};
                pitches.forEach(p => {
                    if (uniqueStringsMap[p.string] === undefined ||
                        p.fret > uniqueStringsMap[p.string].fret) {
                        uniqueStringsMap[p.string] = p;
                    }
                });
                pitches = Object.values(uniqueStringsMap);

                const isRestEvent = pitches.length === 0 && !event.tie;

                if (!detectedRhythm && event.duration !== undefined) {
                    if (isRestEvent) {
                        if (event.duration === 1.5) detectedRhythm = "r.";
                        else if (event.duration === 1.0) detectedRhythm = "r";
                        else if (event.duration === 0.5) detectedRhythm = "r+";
                        else if (event.duration === 0.25) detectedRhythm = "r=";
                    } else {
                        if (event.duration === 6.0) detectedRhythm = "o.";
                        else if (event.duration === 4.0) detectedRhythm = "o";
                        else if (event.duration === 3.0) detectedRhythm = "..";
                        else if (event.duration === 2.0) detectedRhythm = ".";
                        else if (event.duration === 1.5) detectedRhythm = ":.";
                        else if (event.duration === 1.0) detectedRhythm = ":";
                        else if (event.duration === 0.75) detectedRhythm = "+.";
                        else if (event.duration === 0.5) detectedRhythm = "+";
                        else if (event.duration === 0.25) detectedRhythm = "=";
                    }
                }

                const beatValue = event.duration !== undefined
                    ? event.duration
                    : RHYTHM_BEAT_VALUES[detectedRhythm || ":"] || 1.0;
                const voice = event.voice || 1;
                const startBeat = absoluteMeasureStartBeat + localCursors[voice];

                localCursors[voice] += beatValue;

                // Create a unique key for this specific beat location per voice
                const mergeKey = `${voice}_${startBeat}`;

                if (!beatMergeMap[mergeKey]) {
                    // Initialize the unique entry for this beat
                    beatMergeMap[mergeKey] = {
                        ...event,
                        pitches: [...pitches],
                        descriptions: event.description
                            ? [event.description]
                            : [],
                        rhythm: detectedRhythm || (isRestEvent ? "r" : ":"),
                        beatValue,
                        isRest: isRestEvent ||
                            (detectedRhythm && detectedRhythm.startsWith("r")),
                        voice,
                        startBeat,
                        measureNumber: mNum,
                        isTiedToNext: !!event.tie
                    };
                } else {
                    // If a note already exists on this exact beat, combine its properties!
                    // 1. Merge pitches arrays seamlessly
                    beatMergeMap[mergeKey].pitches = [
                        ...beatMergeMap[mergeKey].pitches,
                        ...pitches
                    ];

                    // 2. Accumulate descriptions instead of overwriting
                    if (event.description) {
                        beatMergeMap[mergeKey].descriptions.push(
                            event.description
                        );
                    }

                    // 3. Ensure tie states carry over
                    if (event.tie) {
                        beatMergeMap[mergeKey].isTiedToNext = true;
                    }
                }
            });

            // 1. Find all beatslices in this measure that contain a real note
            const activeBeatsInMeasure = new Set(
                Object.values(beatMergeMap)
                    .filter(event => !event.isRest && !event.isMetronomeTick)
                    .map(event => event.startBeat)
            );

            // 2. Suppress rest rendering properties if an active note shares the same beat slice
            Object.values(beatMergeMap).forEach(event => {
                if (event.isRest && activeBeatsInMeasure.has(event.startBeat)) {
                    // Turn off structural flags so svgUtils skips rendering the symbols
                    event.isRest = false; 
                    event.rhythm = ""; // Empties the text string label in the rhythm lane
                    event.pitches = []; // Ensures no phantom pitches exist
                    
                    // Optional: keep a label for debug tools if necessary
                    if(event.descriptions) {
                        event.descriptions.push("Suppressed Rest");
                    }
                }
            });
            // --- END PASSTHROUGH ---

            // Push the merged beats into our processedEvents array
            Object.values(beatMergeMap).forEach(mergedEvent => {
                // Join descriptions array into a clean single string for your UI display components
                if (mergedEvent.descriptions &&
                    mergedEvent.descriptions.length > 0) {
                    mergedEvent.description =
                        mergedEvent.descriptions.join(" | ");
                    mergedEvent.description = mergedEvent.description.replaceAll("_", " ")
                } else if (!mergedEvent.description) {
                    mergedEvent.description = "";
                }

                // De-duplicate any pitch collisions on identical strings if multiple notes collided
                const finalPitchesMap = {};
                mergedEvent.pitches.forEach(p => {
                    if (finalPitchesMap[p.string] === undefined ||
                        p.fret > finalPitchesMap[p.string].fret) {
                        finalPitchesMap[p.string] = p;
                    }
                });
                mergedEvent.pitches = Object.values(finalPitchesMap);

                processedEvents.push({
                    ...mergedEvent,
                    globalIndex: globalEventIndex++
                });
            });

            absoluteMeasureStartBeat += beatsPerMeasure;
        });

        // Calculate total measures based directly on the JSON structure
        const totalMeasures = scoreData.measures.length > 0
            ? Math.max(...scoreData.measures.map(m => m.measureNumber))
            : 1;

        // 3. Build validity and polyphony checks early
        const measureValidityMap = {};
        for (let m = 1; m <= totalMeasures; m++) {
            const evs1 = processedEvents.filter(
                ev => ev.measureNumber === m && ev.voice === 1
            );
            const evs2 = processedEvents.filter(
                ev => ev.measureNumber === m && ev.voice === 2
            );

            const sum1 = evs1.reduce((s, e) => s + (e.beatValue || 0), 0);
            const sum2 = evs2.reduce((s, e) => s + (e.beatValue || 0), 0);

            const present1 = evs1.length > 0;
            const present2 = evs2.length > 0;

            const valid1 = present1
                ? Math.abs(sum1 - beatsPerMeasure) < 1e-6
                : true;
            const valid2 = present2
                ? Math.abs(sum2 - beatsPerMeasure) < 1e-6
                : true;

            measureValidityMap[m] = {
                sum1,
                sum2,
                present1,
                present2,
                valid1,
                valid2,
                valid: valid1 && valid2,
                isPolyphonic: present1 && present2 // Tracks if this specific measure needs dual-voice formatting
            };
        }

        const measureTimeSlotsMap = {};
        processedEvents.forEach(ev => {
            if (!measureTimeSlotsMap[ev.measureNumber]) {
                measureTimeSlotsMap[ev.measureNumber] = new Set();
            }
            measureTimeSlotsMap[ev.measureNumber].add(ev.startBeat);
        });

        const measureSortedSlots = {};
        Object.keys(measureTimeSlotsMap).forEach(mNum => {
            measureSortedSlots[mNum] = Array.from(
                measureTimeSlotsMap[mNum]
            ).sort((a, b) => a - b);
        });

        const computedRows = [];

        for (let i = 0; i < totalMeasures; i += measuresPerRow) {
            const rowMeasuresCount = Math.min(
                measuresPerRow,
                totalMeasures - i
            );

            const barlineXPositions = [];
            const measureGroups = [];
            let currentXPointer = paddingX;

            for (let j = 0; j < rowMeasuresCount; j++) {
                const measureNum = i + j + 1;
                const uniqueSlotsCount = measureSortedSlots[measureNum]?.length || 1;

                const mWidth = uniqueSlotsCount * SLOT_WIDTH + MEASURE_PADDING * 2;
                const startX = currentXPointer;
                const endX = startX + mWidth;

                measureGroups.push({
                    measureNumber: measureNum,
                    startX: startX,
                    endX: endX,
                    slotsArray: measureSortedSlots[measureNum] || []
                });

                currentXPointer = endX;
                barlineXPositions.push(endX);
            }

            const rowEndX = currentXPointer;
            const TOTAL_SVG_WIDTH = rowEndX + 40;

            const rowEvents = processedEvents
                // 4. Safely filter notes into rows using measure number, not float beats
                .filter(
                    ev => ev.measureNumber > i &&
                        ev.measureNumber <= i + rowMeasuresCount
                )
                .map(ev => {
                    const mGroup = measureGroups.find(
                        g => g.measureNumber === ev.measureNumber
                    );
                    const slotIndex = mGroup.slotsArray.indexOf(ev.startBeat);
                    const cx = mGroup.startX +
                        MEASURE_PADDING +
                        slotIndex * SLOT_WIDTH +
                        SLOT_WIDTH / 2;

                    const processedPitches = ev.pitches.map(p => {
                        const tabY = tabTopY + (p.string - 1) * lineSpacing;
                        
                        const midi = TUNING[p.string].baseMidi + p.fret;
                        const clef = midi >= 60 ? "treble" : "bass";
                        const clefTopY = clef === "treble" ? trebleTopY : bassTopY;
                        const pitchProps = parsePitchProperties(
                            midi,
                            clef,
                            clefTopY,
                            lineSpacing
                        );
                        return {
                            ...p,
                            tabY,
                            staffY: pitchProps.y,
                            isSharp: pitchProps.isSharp,
                            midi,
                            clef,
                            noteName: `${NOTE_NAMES[midi % 12]}${Math.floor(midi / 12) - 1}`
                        };
                    });

                    const treblePitches = processedPitches.filter(
                        p => p.clef === "treble"
                    );
                    const bassPitches = processedPitches.filter(
                        p => p.clef === "bass"
                    );

                    const isPolyphonicMeasure = measureValidityMap[ev.measureNumber]?.isPolyphonic;

                    const computeStaffStemData = (pitches, midLineMidi) => {
                        if (pitches.length === 0) return null;
                        const staffYs = pitches.map(p => p.staffY);
                        const lowestY = Math.max(...staffYs);
                        const highestY = Math.min(...staffYs);
                        const avgMidi = pitches.reduce((sum, p) => sum + p.midi, 0) /
                            pitches.length;

                        // 5. Stem direction correctly flips only if the local measure has two voices
                        let stemDown = avgMidi >= midLineMidi;
                        if (isPolyphonicMeasure) stemDown = ev.voice === 2;

                        return { lowestY, highestY, stemDown };
                    };

                    const hasMutedPitch = processedPitches.some(p => p.fret === null);
                    const updatedDescription = hasMutedPitch
                        ? ev.description
                            ? `${ev.description}`
                            : "Muted string"
                        : ev.description;

                    return {
                        ...ev,
                        description: updatedDescription,
                        cx,
                        processedPitches,
                        trebleStem: computeStaffStemData(treblePitches, 71),
                        bassStem: computeStaffStemData(bassPitches, 50)
                    };
                });

            computedRows.push({
                rowEvents,
                totalWidth: TOTAL_SVG_WIDTH,
                barlineXPositions,
                measureGroups,
                rowEndX
            });
        }

        return {
            computedRows,
            timeSigTop,
            timeSigBottom,
            paddingX,
            trebleTopY,
            bassTopY,
            tabTopY,
            rhythmTopY,
            lineSpacing,
            SLOT_WIDTH,
            measureValidityMap,
            beatsPerMeasure
        };
    }, [scoreData, slotWidth, measuresPerRow]);
}