using System; using System.IO; using System.Collections.Generic; using TMPro; using UnityEngine; using UnityEngine.U2D; namespace ABCUnity { public class Layout : MonoBehaviour { [SerializeField] private SpriteAtlas spriteAtlas; // set in editor [SerializeField] public Color color = Color.black; [SerializeField] public Material NoteMaterial; [SerializeField] public TextMeshPro textPrefab; [SerializeField] public float staffLinePadding = 0.4f; [SerializeField] public float staffLineMargin = 1.0f; [SerializeField] public bool overrideLineBreaks = false; #region Callbacks public delegate void OnLoaded(ABC.Tune tune); public OnLoaded onLoaded; #endregion #region TuneSpecificMembers List layouts = new List(); Dictionary beams; public ABC.Tune tune { get; private set; } GameObject scoreContainer; public Dictionary gameObjectMap { get; } = new Dictionary(); public Dictionary itemMap { get; } = new Dictionary(); private Dictionary> spriteRendererCache = new Dictionary>(); private TimeSignature timeSignature; #endregion private float layoutScale = 1.0f; public RectTransform rectTransform { get; private set; } private SpriteCache cache; private NoteCreator notes; private bool multilineLayout; Vector2 staffOffset; private float horizontalMax; #region Constants public const float staffPadding = 0.3f; public const float measurePadding = 0.5f; public const float noteAdvance = 0.75f; const float minimumAdavance = 0.25f; const float staffHeight = 2.29f; #endregion public void Awake() { multilineLayout = !overrideLineBreaks; rectTransform = GetComponent(); cache = new SpriteCache(spriteAtlas, textPrefab); layouts.Clear(); if (beams != null) beams.Clear(); NoteMaterial = GameObject.Instantiate(NoteMaterial); NoteMaterial.color = color; } public void Clear() { if (tune == null) return; GameObject.Destroy(scoreContainer); layouts.Clear(); gameObjectMap.Clear(); itemMap.Clear(); spriteRendererCache.Clear(); timeSignature = null; tune = null; } public void LoadString(string abc) { try { Clear(); tune = ABC.Tune.Load(abc); LayoutTune(); onLoaded?.Invoke(tune); } catch (ABC.ParseException e) { Debug.Log(e.Message); } } public void LoadStream(Stream stream) { try { Clear(); tune = ABC.Tune.Load(stream); LayoutTune(); onLoaded?.Invoke(tune); } catch (ABC.ParseException e) { Debug.Log(e.Message); } } public void LoadFile(string path) { using (FileStream file = File.OpenRead(path)) { LoadStream(file); } } public GameObject FindItemRootObject(GameObject obj) { while (!itemMap.ContainsKey(obj)) obj = obj.transform.parent.gameObject; return obj; } public bool SetItemColor(ABC.Item item, Color color) { if (gameObjectMap.TryGetValue(item.id, out GameObject obj)) { if (!spriteRendererCache.TryGetValue(item.id, out var spriteRenderers)) { spriteRenderers = Util.GatherSpriteRenderers(obj); spriteRendererCache[item.id] = spriteRenderers; } foreach (var spriteRenderer in spriteRenderers) spriteRenderer.color = color; return true; } return false; } public void ResetItemColors() { foreach (var spriteRenderes in spriteRendererCache.Values) { foreach (var spriteRenderer in spriteRenderes) spriteRenderer.color = color; } } public Alignment GetAlignment(int i) { return layouts[i].alignment; } void SetupVoiceLayouts() { // create the layout structures for each voice int measureCount = 0; for (int i = 0; i < tune.voices.Count; i++) { var layout = new VoiceLayout(tune.voices[i]); layouts.Add(layout); layout.Init(multilineLayout); if (i == 0) measureCount = layout.alignment.measures.Count; else if (layout.alignment.measures.Count != measureCount) throw new LayoutException("All voices must have the same measure count"); } } void LayoutScoreLine(int lineNum) { for (int measure = 0; measure < layouts[0].scoreLines[lineNum].measures.Count; measure++) { float advanceAmount = measurePadding; foreach (var layout in layouts) { layout.beatIndex = 0; var scoreLine = layout.scoreLines[lineNum]; var measureInfo = scoreLine.measures[measure]; measureInfo.container = new GameObject("Measure"); } float maxBeatX = 0.0f; for (int beat = 1; beat <= timeSignature.beatCount; beat++) { int beatItemIndex = 0; while (true) { bool more = false; float alignment = 0.0f; //layout foreach (var layout in layouts) { var scoreLine = layout.scoreLines[lineNum]; var layoutMeasure = scoreLine.measures[measure]; var beatInfo = layoutMeasure.source.beats[layout.beatIndex]; // advance // in order to preserve alignment, all layouts will advance to the furthest position of the current beat marker var delta = maxBeatX - layoutMeasure.insertX; layoutMeasure.spacers.Add(delta); layoutMeasure.AdvaceInsertPos(delta); // if this beat is the start of a new group of notes render them if (beatInfo.beatStart == beat && beatItemIndex < beatInfo.items.Count) { var element = layoutMeasure.AddItem(beatInfo.items[beatItemIndex]); switch (element.item.type) { case ABC.Item.Type.Note: CreateNoteSprite(layout.voice.clef, element); break; case ABC.Item.Type.Chord: CreateChordSprite(layout.voice.clef, element); break; case ABC.Item.Type.Rest: CreateRestSprite(element); break; case ABC.Item.Type.MultiMeasureRest: CreateMeasureRestSprite(element); break; } gameObjectMap.Add(element.item.id, element.container); itemMap.Add(element.container, element.item); // position var beatItem = layoutMeasure.elements[layoutMeasure.elements.Count - 1]; float alignPos = SetItemReferencePosition(beatItem, layoutMeasure, advanceAmount); alignment = Mathf.Max(alignment, alignPos); beatItem.container.transform.parent = layoutMeasure.container.transform; } else { layoutMeasure.AddItem(null); layoutMeasure.spacers[layoutMeasure.spacers.Count - 1] += noteAdvance; layoutMeasure.AdvaceInsertPos(noteAdvance); } } // align foreach (var layout in layouts) { var scoreLine = layout.scoreLines[lineNum]; var layoutMeasure = scoreLine.measures[measure]; var beatInfo = layoutMeasure.source.beats[layout.beatIndex]; if (beatInfo.beatStart == beat && beatItemIndex < beatInfo.items.Count) { var beatItem = layoutMeasure.elements[layoutMeasure.elements.Count - 1]; float alignPos = layoutMeasure.insertX - beatItem.totalWidth + beatItem.prefixAmount; float delta = alignment - alignPos; layoutMeasure.spacers[layoutMeasure.spacers.Count - 1] += delta; layoutMeasure.AdvaceInsertPos(delta); bool allItemsDone = beatItemIndex == beatInfo.items.Count - 1; if (allItemsDone) layout.beatIndex = Math.Min(layoutMeasure.source.beats.Count - 1, layout.beatIndex + 1); else more = true; } maxBeatX = Math.Max(maxBeatX, layoutMeasure.bounds.size.x); } beatItemIndex += 1; advanceAmount = noteAdvance; if (!more) break; } } // after all items in the measure are placed, add the measure's bar foreach (var layout in layouts) { var layoutMeasure = layout.scoreLines[lineNum].measures[measure]; var delta = maxBeatX - layoutMeasure.insertX; layoutMeasure.spacers.Add(delta); layoutMeasure.AdvaceInsertPos(delta); var element = layoutMeasure.AddItem(layoutMeasure.source.bar); CreateBarSprite(element); SetItemReferencePosition(element, layoutMeasure, advanceAmount); element.container.transform.parent = layoutMeasure.container.transform; } } } void PositionScoreLine(int lineNum) { float startX = float.MinValue; foreach (var layout in layouts) { var scoreLine = layout.scoreLines[lineNum]; scoreLine.container = new GameObject("ScoreLine"); LayoutStaff(scoreLine, layout.voice.clef); startX = Mathf.Max(startX, scoreLine.insertX); } foreach (var layout in layouts) { var scoreLine = layout.scoreLines[lineNum]; scoreLine.AdvaceInsertPos(startX - scoreLine.insertX + staffPadding); } if (lineNum == 0) { foreach (var layout in layouts) { var scoreLine = layout.scoreLines[lineNum]; LayoutTimeSignature(scoreLine, layout.voice.initialTimeSignature); } } var measureWidths = CalculateMeasureWidths(lineNum); foreach (var layout in layouts) { var scoreLine = layout.scoreLines[lineNum]; for (int i = 0; i < scoreLine.measures.Count; i++) { var measure = scoreLine.measures[i]; Bounds actualMeasureBounds; if (measure.source.isRest) actualMeasureBounds = PositionMeasureRest(measure, measureWidths[i]); else actualMeasureBounds = PositionMeasureItems(measure, measureWidths[i]); measure.container.transform.parent = scoreLine.container.transform; measure.container.transform.localPosition = scoreLine.insertPos; scoreLine.EncapsulateAppendedBounds(actualMeasureBounds); } } FinalizeScoreLine(lineNum); } float[] CalculateMeasureWidths(int lineNum) { var scoreLine = layouts[0].scoreLines[lineNum]; float[] measureWidths = new float[scoreLine.measures.Count]; float referenceWidth = 0.0f; float allocatedSpace = horizontalMax - scoreLine.insertX; for (int i = 0; i < scoreLine.measures.Count; i++) { measureWidths[i] = scoreLine.measures[i].insertX; referenceWidth += measureWidths[i]; } for (int i = 0; i < scoreLine.measures.Count; i++) measureWidths[i] = (measureWidths[i] / referenceWidth) * allocatedSpace; return measureWidths; } /// /// Sets the position of a full measure rest such that it is centered in the available space. /// Positions the bar first, then centers the rest in the remaining space. /// Bounds PositionMeasureRest(VoiceLayout.ScoreLine.Measure measure, float allocatedWidth) { var bar = measure.elements[measure.elements.Count - 1]; float barX = allocatedWidth - bar.totalWidth; bar.container.transform.localPosition = new Vector3(barX, 0.0f, 0.0f); var rest = measure.elements[0]; var measureCenter = barX / 2.0f; var posX = measureCenter - rest.info.totalBounding.size.x / 2.0f; rest.container.transform.localPosition = new Vector3(posX, 0.0f, 0.0f); Bounds actualBounds = new Bounds(Vector3.zero, Vector3.zero); actualBounds.Encapsulate(new Vector3(allocatedWidth, 0.0f, 0.0f)); return actualBounds; } Bounds PositionMeasureItems(VoiceLayout.ScoreLine.Measure measure, float actualmeasureWidth) { Bounds actualBounds = new Bounds(Vector3.zero, Vector3.zero); List beamVertices = null; float positionX = 0.0f; float deltaWidth = actualmeasureWidth - measure.minWidth; float deltaWidth2 = actualmeasureWidth - measure.insertX; float spacerAdjust = deltaWidth / (measure.elements.Count - 1); for (int i = 0; i < measure.elements.Count; i++) { var item = measure.elements[i]; positionX += measure.spacers[i]; if (i > 0) positionX += spacerAdjust; if (item.item == null) continue; Vector3 insertPos = new Vector3(positionX, 0.0f, 0.0f); item.container.transform.localPosition = insertPos; positionX += item.totalWidth; var actualNoteInfo = item.OffsetNoteInfo(insertPos); actualBounds.Encapsulate(actualNoteInfo.totalBounding); var duration = item.item as ABC.Duration; if (duration != null && beams.TryGetValue(duration.beam, out Beam beam)) { beam.Update(actualNoteInfo.rootBounding); if (beam.isReadyToCreate) { if (beam.type == Beam.Type.Angle) { if (beamVertices == null) beamVertices = new List(); beam.CreateAngledBeam(beamVertices); } else { beam.CreateBasicBeam(cache, measure.container); } } } } if (beamVertices != null) Beam.CreateMesh(beamVertices, NoteMaterial, measure.container); return actualBounds; } class StafflineHeight { public readonly float min, max; public StafflineHeight(float min, float max) { this.min = min; this.max = max; } public float val { get { return max - min; } } } private static readonly Dictionary baseStaffValues = new Dictionary() { { ABC.Clef.Treble, new StafflineHeight(-0.9f, 3.0f) }, { ABC.Clef.Bass, new StafflineHeight(0.0f, 2.3f) }, }; float CalculateScoreHeight() { float height = 0.0f; for (int i = 0; i < layouts[0].scoreLines.Count; i++) { //compute height of this score line for (int l = 0; l < layouts.Count; l++) { var baseValues = baseStaffValues[layouts[l].voice.clef]; float min = baseValues.min; float max = baseValues.max; foreach (var measure in layouts[l].scoreLines[i].measures) { min = Mathf.Min(min, measure.bounds.min.y); max = Mathf.Max(max, measure.bounds.max.y); } var staffSpacer = l == layouts.Count - 1 ? staffLineMargin : staffLinePadding; height += (max - min) + staffSpacer; } } return height; } void PrepareScoreLines() { if (multilineLayout) { for (int i = 0; i < layouts[0].scoreLines.Count; i++) LayoutScoreLine(i); } else { LayoutScoreLine(0); PartitionScoreLine(); } float calculatedHeight = CalculateScoreHeight(); var rectSize = rectTransform.sizeDelta; if (calculatedHeight > rectSize.y) layoutScale = rectSize.y / calculatedHeight; horizontalMax = rectSize.x * 1.0f / layoutScale; staffOffset = new Vector2(-rectSize.x / 2.0f, 0); } void LayoutTune() { if (tune == null) return; notes = new NoteCreator(cache); cache.color = color; timeSignature = GetTimeSignature(); Vector3 scale = this.gameObject.transform.localScale; this.gameObject.transform.localScale = Vector3.one; beams = Beam.CreateBeams(tune); SetupVoiceLayouts(); scoreContainer = new GameObject("Score"); scoreContainer.transform.parent = this.transform; scoreContainer.transform.localPosition = Vector3.zero; PrepareScoreLines(); for (int i = 0; i < layouts[0].scoreLines.Count; i++) PositionScoreLine(i); scoreContainer.transform.localScale = new Vector3(layoutScale, layoutScale, layoutScale); this.gameObject.transform.localScale = scale; } /// Breaks up a single score line into multiple lines based on the horizontal max void PartitionScoreLine() { // save off the scorelines that were laid out int measureCount = layouts[0].scoreLines[0].measures.Count; var scoreLines = new List[layouts.Count]; for (int i = 0; i < scoreLines.Length; i++) { scoreLines[i] = layouts[i].scoreLines[0].measures; layouts[i].scoreLines.Clear(); layouts[i].scoreLines.Add(new VoiceLayout.ScoreLine()); } float currentWidth = 0.0f; for (int measureIndex = 0; measureIndex < measureCount; measureIndex++) { float measureWidth = float.MinValue; foreach (var scoreLine in scoreLines) measureWidth = Mathf.Max(measureWidth, scoreLine[measureIndex].insertX); // ensure that the measure of each scoreline will fit on the current line. // If it wont fit make a new line foreach (var scoreLine in scoreLines) { if (currentWidth + scoreLine[measureIndex].insertX > horizontalMax) { foreach (var layout in layouts) layout.scoreLines.Add(new VoiceLayout.ScoreLine()); currentWidth = 0.0f; break; } } //Add the current measure to the last scoreline for (int i = 0; i < scoreLines.Length; i++) { var scoreLine = layouts[i].scoreLines[layouts[i].scoreLines.Count - 1]; scoreLine.measures.Add(scoreLines[i][measureIndex]); currentWidth += measureWidth; } } } TimeSignature GetTimeSignature() { TimeSignature result = null; for (int i = 0; i < tune.voices.Count; i++) { var initialTimeSignature = tune.voices[i].initialTimeSignature; if (initialTimeSignature == string.Empty) throw new BeatAlignmentException($"Voice {i} does not initially declare a time signature."); var timeSignature = TimeSignature.Parse(initialTimeSignature); if (result == null) result = timeSignature; else if (!timeSignature.Equals(result)) throw new LayoutException("All voices should have the same time signature"); } return result; } /// Calculates the final size of the staff and positions it correctly relative to the container. void FinalizeScoreLine(int lineNum) { //foreach (var layout in layouts) for (int i = 0; i < layouts.Count; i++) { var scoreLine = layouts[i].scoreLines[lineNum]; AdjustStaffScale(scoreLine); scoreLine.container.transform.parent = scoreContainer.transform; scoreLine.container.transform.localPosition = new Vector3(staffOffset.x * (1.0f / layoutScale), staffOffset.y - (scoreLine.bounds.max.y), 0.0f); var staffSpacer = i == layouts.Count - 1 ? staffLineMargin : staffLinePadding; staffOffset.y -= (scoreLine.bounds.size.y + staffSpacer); } RenderConnectorBar(lineNum); } void RenderConnectorBar(int lineNum) { var barSprite = cache.GetSpriteObject("Bar_Line"); barSprite.transform.parent = scoreContainer.transform; var topScoreLine = layouts[0].scoreLines[lineNum]; var bottomScoreLine = layouts[layouts.Count - 1].scoreLines[lineNum]; var barPos = bottomScoreLine.container.transform.localPosition; var topPos = topScoreLine.container.transform.localPosition.y + staffHeight; var targetHeight = topPos - barPos.y; var scale = targetHeight / barSprite.bounds.size.y; barSprite.transform.localPosition = barPos; barSprite.transform.localScale = new Vector3(1.0f, scale, 1.0f); } void LayoutStaff(VoiceLayout.ScoreLine scoreLine, ABC.Clef clef) { var staffSprite = cache.GetSpriteObject("Staff"); staffSprite.transform.parent = scoreLine.container.transform; staffSprite.transform.localPosition = scoreLine.insertPos; scoreLine.bounds = staffSprite.bounds; scoreLine.AdvaceInsertPos(staffPadding); var clefSprite = cache.GetSpriteObject($"Clef_{clef}"); clefSprite.transform.parent = scoreLine.container.transform; clefSprite.transform.localPosition = scoreLine.insertPos; scoreLine.bounds.Encapsulate(clefSprite.bounds); scoreLine.AdvaceInsertPos(staffPadding); } void LayoutTimeSignature(VoiceLayout.ScoreLine scoreLine, string timeSignature) { var container = new GameObject("Time Signature"); container.transform.parent = scoreLine.container.transform; var timeSignatureInfo = notes.CreateTimeSignature(timeSignature, container); container.transform.localPosition = scoreLine.insertPos; scoreLine.EncapsulateAppendedBounds(timeSignatureInfo.totalBounding); } void AdjustStaffScale(VoiceLayout.ScoreLine scoreLine) { var currentStaff = scoreLine.container.transform.GetChild(0); var currentWidth = currentStaff.GetComponent().bounds.size.x; var scaleX = scoreLine.insertX / currentWidth; currentStaff.transform.localScale = new Vector3(scaleX, 1.0f, 1.0f); } float SetItemReferencePosition(VoiceLayout.ScoreLine.Element element, VoiceLayout.ScoreLine.Measure measure, float advanceAmount) { float spacer = Mathf.Max(minimumAdavance, advanceAmount - element.prefixAmount); float alignmentPos = measure.insertX + spacer + element.prefixAmount; measure.spacers[measure.spacers.Count - 1] += spacer; measure.AdvaceInsertPos(spacer); measure.EncapsulateAppendedBounds(element.info.totalBounding); return alignmentPos; } void CreateChordSprite(ABC.Clef clef, VoiceLayout.ScoreLine.Element element) { var chordItem = element.item as ABC.Chord; element.container = new GameObject("Chord"); tune.decorations.TryGetValue(chordItem.id, out var decorations); NoteInfo chordInfo; if (beams.TryGetValue(chordItem.beam, out Beam beam)) chordInfo = notes.CreateChord(chordItem, beam, decorations, element.container); else chordInfo = notes.CreateChord(chordItem, clef, decorations, element.container); element.info = chordInfo; } void CreateNoteSprite(ABC.Clef clef, VoiceLayout.ScoreLine.Element element) { var noteItem = element.item as ABC.Note; element.container = new GameObject("Note"); tune.decorations.TryGetValue(noteItem.id, out var decorations); NoteInfo noteInfo; if (beams.TryGetValue(noteItem.beam, out Beam beam)) noteInfo = notes.CreateNote(noteItem, beam, decorations, element.container); else noteInfo = notes.CreateNote(noteItem, clef, decorations, element.container); element.info = noteInfo; } void CreateRestSprite(VoiceLayout.ScoreLine.Element element) { var restItem = element.item as ABC.Rest; element.container = new GameObject("Rest"); element.info = notes.CreateRest(restItem, element.container); } void CreateBarSprite(VoiceLayout.ScoreLine.Element element) { var barItem = element.item as ABC.Bar; element.container = new GameObject("Bar"); element.info = notes.CreateBar(barItem, element.container); } void CreateMeasureRestSprite(VoiceLayout.ScoreLine.Element element) { var measureRest = element.item as ABC.MultiMeasureRest; element.container = new GameObject("Rest"); element.info = notes.CreateMeasureRest(measureRest, element.container); } } }