Thursday, 2 August 2012

Time Signatures

The regular work days are long and the hour is late but I've got to try and get 30 mins to an hour in each day or this won't move ahead. We're starting on the time signature elements that sit on the staff.

Let's start with an object that represents a time signature. Seeing as the English language doesn't have an agreed lexicon for the top and bottom number you see in a time signature, we face our first decision.

The top number represents the number of beats in a bar, the bottom number tells you the note duration of each beat. Then consider if the top number is greater than 4 then we're in compound time, so you divide the top number by 3 to get the number of beats in the bar. So 2/4 means 2 quarter notes (crotchets) in a bar, and 6/8 means 2 eighth notes (quavers) in a bar.

Then there's C and C with a vertical bar down the middle of it. These are taken today to mean Common and Cut Common time, namely 4/4 and 2/2 respectively. The C is alleged to have come from Circa Perfectum, meaning the perfect circle, i.e. the representation of the whole note. Just stick with Common time for now, I can see why you'd say whole note because 4/4 is 4 times a quarter note. Moving on!

A simple java object to represent this would be

package score.notes.timesigs;

import java.awt.Color;
import java.awt.Font;
import java.awt.FontMetrics;
import java.awt.Graphics;
import java.awt.Graphics2D;

import score.notes.JDrumNote;
import score.notes.JStaffElement;

public class TimeSignature extends JStaffElement {

 private static final long serialVersionUID = 1L;
 
 private int top=2;
 private int bottom=2;
 private boolean useCommon=false;
 
 public int getTop() {
  return top;
 }
 
 public void setTop(int top) {
  this.top = top;
  setUseCommon(false);
 }
 
 public int getBottom() {
  return bottom;
 }
 
 public void setBottom(int bottom) {
  this.bottom = bottom;
  setUseCommon(false);
 }

 public boolean isUseCommon() {
  return useCommon;
 }

 private void setUseCommon(boolean useCommon) {
  this.useCommon = useCommon;
 }
 
}

In this class we represent the top and bottom numbers as you'd expect and we deal with the optional use of Common and Cut Common notation using the boolean useCommon as a flag for the mechanism the user has favoured.

Next we need to consider the rendering of a time signature. All the drawing in the drum score app is performed using 2D shapes, with separate zooming and scaling factors applied. The approach taken this time is to use the built-in Java fonts that we can guarantee are available on all platforms. We are warned that in some locales the internationalized characters cannot be guaranteed to render the same everywhere. On the other hand bundling physical fonts with the app to guarantee both their presence and common appearance is a major overhead. To be honest, I'm considering figuring out some Path2D constructs to represent the shapes we need and doing away with fonts entirely - maybe in the future huh!

So to implement this so it works within the zooming and scaling architecture of the score app, we need to derive our object from JStaffElement. This means we need to provide a preDelete hook and a scaleChanged hook.

The preDelete hook is empty in the case of this object, the user can delete it with no consequence to anything around it (this construct is needed for notes which may participate in a tie arrangement with another note, if that's the case we need to delete the tie).

The scaleChanged hook is where we scale the font size in use, to match the scaling of the rest of the document. Note scaling can happen in two ways, firstly that the user wants to zoom in on the screen to fine tune position or just get a better look (zoomFactor), or the user decides they want all the drawing artefacts to be bigger in relation to the page (scaleFactor).

 protected void scaleChanged() {
  double pointSize=getPointSize();
  double zoomFactor=getZoomFactor();
  
  double basePointSize=6.5f; // hardcoding to same as a note's base so it scales proportionately
  double pointScaleFactor=pointSize/basePointSize; 
  
  scaledFontSize = zoomFactor * pointScaleFactor * modelFontSize;
  scaledCutLineWidth = zoomFactor * pointScaleFactor * modelCutLineWidth;
 }
 
The technique used throughout the app is that there's a model size for the artefact and these have been manually baselined across all the artefacts so they start from the same point, and then every change in scale results in the scaledFontSize being recalculated - this is what's used in the paint() routine.

public void paint(Graphics g, JStaffElement prevElement, JDrumNote prevNote, JStaffElement nextElement) {
  super.paint(g,prevElement,prevNote,nextElement);
 
  Graphics2D g2D = (Graphics2D) g;
  Color saveColor = g2D.getColor();
  g2D.setColor(getDrawColor());
  
  // need to get some sizing details from the font. Would prefer not to have to do this in paint() and prepare everything
  // when we set zoom or point size factors.
  
  if (!useCommon) {
   // draw regular numbers
   Font font = new Font(Font.SERIF, Font.PLAIN, (int)scaledFontSize);
   FontMetrics metrics = g2D.getFontMetrics(font);
   g2D.setFont(font);

   // get the height of a line of text in this
   // font and render context
   double fontAscent = metrics.getAscent();
   double fontDescent = metrics.getDescent();

   // figure the width of the larger of top and bottom 
   double topWidth = metrics.stringWidth(Integer.toString(top));
   double bottomWidth = metrics.stringWidth(Integer.toString(bottom));
   setElementWidth(Math.max(topWidth,bottomWidth));

   // gotta figure out where we draw the top and bottom numbers from, x is easy ...
   double tsX = getX();
   double tsTopY = getY() + getAnchorHeight(); // this gives us where the staff line is
   tsTopY -= fontDescent;

   double tsBottomY = getY() + getAnchorHeight() + fontAscent;
   
   g2D.drawString(Integer.toString(top),(float)tsX,(float)tsTopY);
   g2D.drawString(Integer.toString(bottom),(float)tsX,(float)tsBottomY);
  } else {
   // use Common and Cut Common notation
   String common="C";
   Font font = new Font(Font.SANS_SERIF, Font.PLAIN, (int)scaledFontSize);
   FontMetrics metrics = g2D.getFontMetrics(font);
   g2D.setFont(font);

   setElementWidth(metrics.stringWidth(common));
   
   double tsX = getX();
   double tsY = getY() + getAnchorHeight() + (metrics.getAscent()/2) - metrics.getLeading();
   g2D.drawString(common,(float)tsX,(float)tsY);
   
   // need to fill a shape that represents the vertical line if it's cut common
   if (top == 2) {
    
    // the x point is halfway along the width of the C, less half of the width of the line
    double cutX=getX()+(metrics.stringWidth(common)/2.0f);
    
    // the start y point is half of the height of the font above the C
    double cutY=tsY-metrics.getHeight();
 
    // the depth is 40% more than the height of the font
    double cutLength=metrics.getHeight()+(0.4*metrics.getHeight());
    
    cutLine.setFrame(cutX,cutY,scaledCutLineWidth,cutLength);
    g2D.fill(cutLine);
   }
  }
  
  // restore environment
  g2D.setColor(saveColor);
 }

Firstly, note there's a bunch of parameters you won't expect on a regular Swing paint() call, these are not relevant to the TimeSignature class, but are in the base class it's derived from as the JDrumNote staff element needs to have a reference to the notes either side of it, to draw ligatures and ties and so on.

Next we call the super as it takes care of highlighting if this object is in a selected state, then we do the usual cast to Graphics2D, and respect the pen colour that the super has set. The rest is pretty standard font stuff, except note the use of doubles. The whole of the application is written with double precision so a very high quality graphics experience is possible in the artwork. Unfortunately the Font implementations only use integer precision for the point size and the ascent / descent. What we do here by casting to double is ensure we don't lose the x and y double precision for placement but round to nearest whole number for the fonts.

It doesn't make any discernible difference on the screen, it's in the printing that I'm hoping we don't get alignment issues. Would be more straightforward if the whole Java2D library converted to double precision, I hope the font stuff is just legacy that hasn't been addressed yet.

Note the need to deal with the Common and Cut Common time in the paint() method. The font metrics bit confused me at first until I realised Height and Ascent etc are for the max size of character in the font, not the one we're using, i.e. the C!

So for reference, here's the complete working object, with all the elements discussed built in.

package score.notes.timesigs;

import java.awt.Color;
import java.awt.Font;
import java.awt.FontMetrics;
import java.awt.Graphics;
import java.awt.Graphics2D;
import java.awt.geom.Rectangle2D;

import score.notes.JDrumNote;
import score.notes.JStaffElement;

public class TimeSignature extends JStaffElement {

 private static final long serialVersionUID = 1L;
 
 final static private double modelFontSize=2.5f;
 final static private double modelCutLineWidth=0.1f;
 
 private int top=2;
 private int bottom=4;
 private boolean useCommon=false;
 
 private double scaledFontSize;
 private double scaledCutLineWidth;
 
 private Rectangle2D cutLine = new Rectangle2D.Double();
 
 public void paint(Graphics g, JStaffElement prevElement, JDrumNote prevNote, JStaffElement nextElement) {
  super.paint(g,prevElement,prevNote,nextElement);
 
  Graphics2D g2D = (Graphics2D) g;
  Color saveColor = g2D.getColor();
  g2D.setColor(getDrawColor());
  
  // need to get some sizing details from the font. Would prefer not to have to do this in paint() and prepare everything
  // when we set zoom or point size factors.
  
  if (!useCommon) {
   // draw regular numbers
   Font font = new Font(Font.SERIF, Font.PLAIN, (int)scaledFontSize);
   FontMetrics metrics = g2D.getFontMetrics(font);
   g2D.setFont(font);

   // get the height of a line of text in this
   // font and render context
   double fontAscent = metrics.getAscent();
   double fontDescent = metrics.getDescent();

   // figure the width of the larger of top and bottom 
   double topWidth = metrics.stringWidth(Integer.toString(top));
   double bottomWidth = metrics.stringWidth(Integer.toString(bottom));
   setElementWidth(Math.max(topWidth,bottomWidth));

   // gotta figure out where we draw the top and bottom numbers from, x is easy ...
   double tsX = getX();
   double tsTopY = getY() + getAnchorHeight(); // this gives us where the staff line is
   tsTopY -= fontDescent;

   double tsBottomY = getY() + getAnchorHeight() + fontAscent;
   
   g2D.drawString(Integer.toString(top),(float)tsX,(float)tsTopY);
   g2D.drawString(Integer.toString(bottom),(float)tsX,(float)tsBottomY);
  } else {
   // use Common and Cut Common notation
   String common="C";
   Font font = new Font(Font.SANS_SERIF, Font.PLAIN, (int)scaledFontSize);
   FontMetrics metrics = g2D.getFontMetrics(font);
   g2D.setFont(font);

   setElementWidth(metrics.stringWidth(common));
   
   double tsX = getX();
   double tsY = getY() + getAnchorHeight() + (metrics.getAscent()/2) - metrics.getLeading();
   g2D.drawString(common,(float)tsX,(float)tsY);
   
   // need to fill a shape that represents the vertical line if it's cut common
   if (top == 2) {
    
    // the x point is halfway along the width of the C, less half of the width of the line
    double cutX=getX()+(metrics.stringWidth(common)/2.0f);
    
    // the start y point is half of the height of the font above the C
    double cutY=tsY-metrics.getHeight();
 
    // the depth is 40% more than the height of the font
    double cutLength=metrics.getHeight()+(0.4*metrics.getHeight());
    
    cutLine.setFrame(cutX,cutY,scaledCutLineWidth,cutLength);
    g2D.fill(cutLine);
   }
  }
  
  // restore environment
  g2D.setColor(saveColor);
 }
 
 public int getTop() {
  return top;
 }
 
 public void setTop(int top) {
  this.top = top;
  setUseCommon(false);
 }
 
 public int getBottom() {
  return bottom;
 }
 
 public void setBottom(int bottom) {
  this.bottom = bottom;
  setUseCommon(false);
 }

 public boolean isUseCommon() {
  return useCommon;
 }

 public void setUseCommon(boolean useCommon) {
  this.useCommon = useCommon;
 }

 @Override
 protected void preDelete() {
  // Nothing to do
 }

 @Override
 protected void scaleChanged() {
  double pointSize=getPointSize();
  double zoomFactor=getZoomFactor();
  
  double basePointSize=6.5f; // hardcoding to same as a note's base so it scales proportionately
  double pointScaleFactor=pointSize/basePointSize; 
  
  scaledFontSize = zoomFactor * pointScaleFactor * modelFontSize;
  scaledCutLineWidth = zoomFactor * pointScaleFactor * modelCutLineWidth;
 }
 
}




No comments:

Post a Comment