TuneGraphsImpl.mesa
programs to plot the energy profile and distribution of a tune
Ades, March 6, 1986 4:34:12 pm PST
DIRECTORY
Commander USING [CommandProc, Register, Handle],
CommandTool USING [ArgumentVector, Failed, Parse],
Containers USING [Container, Create],
Convert USING [RopeFromInt, IntFromRope],
Icons USING [NewIconFromFile],
IO USING [PutF, PutFR, int, STREAM],
Imager USING [Context, MaskRectangle, SetFont, SetColor, SetXY, ShowRope, TranslateT, black, MakeGray],
ImagerColorDefs USING [ConstantColor],
ImagerFont USING [Find, Font, RopeWidth],
Jukebox USING [bytesPerChirp, CloseJukebox, ArchiveCloseTune, Error, FindJukebox, Handle, OpenJukebox, OpenTune, Tune, TuneSize, EnergyRange, instances, singlePktLength, hangoverPackets, MissingChirp, EOF],
Rope USING [Cat, ROPE, Equal, Length, Fetch, Substr],
Vector2 USING [VEC],
ViewerEvents USING [EventProc, RegisterEventProc],
ViewerClasses USING [PaintProc, Viewer, ViewerClass, ViewerClassRec],
ViewerOps USING [CreateViewer, RegisterViewerClass, SetOpenHeight],
TuneAccess USING [EnergyBlock, GetEnergyProfile, ReadAmbientLevel];
TuneGraphsImpl: CEDAR PROGRAM
IMPORTS
Commander, CommandTool, Containers, Convert, Icons, IO, Imager, ImagerFont, Jukebox, Rope, ViewerEvents, ViewerOps, TuneAccess = {
Context: TYPE ~ Imager.Context;
VEC: TYPE ~ Vector2.VEC;
Font: TYPE ~ ImagerFont.Font;
CommandName: TYPE = {plotTune, plotDistribution};
variables and routines used by both commands
Data: TYPE = REF DataRecord;
DataRecord: TYPE = RECORD [
container: Containers.Container ← NIL,
graphs: ViewerClasses.Viewer ← NIL,
commandName: CommandName,
jukeboxName: Rope.ROPENIL,
tuneString: Rope.ROPENIL,
height: INT,
yMax: CARDINAL,
rows: INT,
width: INT,
totalHeight: NAT,
points: REF TwoDArrayOfPoints ← NEW [TwoDArrayOfPoints ← ALL[NIL]],
helvetica6: Font ← ImagerFont.Find["Xerox/TiogaFonts/Helvetica6"],
helvetica8: Font ← ImagerFont.Find["Xerox/TiogaFonts/Helvetica8"],
helvetica10: Font ← ImagerFont.Find["Xerox/TiogaFonts/Helvetica10"],
tuneNumberFont: Font];
leading: NAT = 2;
separation: NAT = 10;
buttonHeight: NAT = 15;
headerHeight: NAT = 12;
left: INT = 50;
bottom: INT = leading+headerHeight+leading;
top: INT = 10;
maxWidth: INT = 500;
gray: ImagerColorDefs.ConstantColor = Imager.MakeGray[0.5];
maxRows: INT = 8; -- most that will fit on a screen [assumes a value of data.height - naughty]
TwoDArrayOfPoints: TYPE = ARRAY [0..maxRows) OF REF ArrayOfPoints;
RealInitialisedToZero: TYPE = REAL ← 0;
ArrayOfPoints: TYPE = RECORD [ s: SEQUENCE max: NAT OF RealInitialisedToZero];
Title: PROC [context: Context, data: Data, t1, t2, t3: Rope.ROPE] ~ {
Imager.SetFont[context, data.helvetica6];
Imager.SetXY[context, [-left+leading+leading, leading+headerHeight]];
Imager.ShowRope[context, t1];
Imager.SetFont[context, data.tuneNumberFont];
Imager.SetXY[context, [-left+leading+leading, leading]];
Imager.ShowRope[context, t2];
Imager.SetFont[context, data.helvetica6];
Imager.SetXY[context, [-left+leading+leading, 2*leading-headerHeight]];
Imager.ShowRope[context, t3];
};
BasicFrame: PROC [context: Context, data: Data, height: INT] ~ {
Imager.SetFont[context, data.helvetica8];
Imager.MaskRectangle[context, [x: -1, y: 0, w: data.width+2, h: -1.0]];
Imager.MaskRectangle[context, [x: 0, y: -1, w: -1.0, h: height+1]];
Imager.MaskRectangle[context, [x: data.width, y: -1, w: 1.0, h: height+1]];
FOR i: INT ← 0, i + 60 UNTIL i > data.width DO
Imager.MaskRectangle[context, [x: i, y: -1, w: -1.0, h: -5.0]];
Imager.SetXY[context, [i+leading, 0+leading-headerHeight]];
ENDLOOP;
};
Frame: PROC [context: Context, data: Data, max: INT] ~ {
BasicFrame[context, data, data.height];
FOR i: INT ← 0, i + 30 UNTIL i > data.height DO
value: INT = max*i/data.height;
tag: Rope.ROPE = AbbRopeFromInt[value];
ropeWidth: VEC = ImagerFont.RopeWidth[data.helvetica8, tag];
Imager.SetXY[context, [0-ropeWidth.x-5-5, i]];
IF i # 0 THEN Imager.ShowRope[context, tag];
Imager.MaskRectangle[context, [x: -1, y: i, w: -5.0, h: -1.0]];
Imager.MaskRectangle[context, [x: data.width+1, y: i, w: +5.0, h: -1.0]];
Imager.SetXY[context, [0+data.width+5+5, i]];
Imager.ShowRope[context, tag];
ENDLOOP;
};
AbbRopeFromInt: PROC [i: INT] RETURNS [rope: Rope.ROPE] = {
IF i = 0 THEN RETURN["0"];
SELECT TRUE FROM
(i MOD 1000) = 0 => rope ← Rope.Cat[Convert.RopeFromInt[i/1000], "K"];
(i MOD 1000000) = 0 => rope ← Rope.Cat[Convert.RopeFromInt[i/1000000], "M"];
ENDCASE => rope ← Convert.RopeFromInt[i];
};
PlotData: PROC [context: Context, data: Data, what: REF ArrayOfPoints, max: INT, t1, t2, t3: Rope.ROPE] = {
scale: REAL = REAL[max]/REAL[data.height];
Imager.TranslateT[context, [0, -(data.height + top)]];
Frame[context, data, max];
FOR i: INT IN [0..data.width) DO
sample: REALREAL[what[i]]/scale
;
IF sample > 0
THEN Imager.SetColor[context, Imager.black]
ELSE {Imager.SetColor[context, gray]; sample ← -sample};
sample ← MIN[sample, REAL[data.height]];
Imager.MaskRectangle[context, [x: i, y: 0, w: 1.0, h: sample]];
ENDLOOP;
Imager.SetColor[context, Imager.black];
Title[context, data, t1, t2, t3];
Imager.TranslateT[context, [0, -bottom]];
};
RepaintViewer: ViewerClasses.PaintProc = {
[self: ViewerClasses.Viewer, context: Imager.Context, whatChanged: REF ANY, clear: BOOL] RETURNS [quit: BOOL ← FALSE]
data: Data = NARROW[self.data];
Imager.TranslateT[context, [left, data.totalHeight]];
FOR i: INT IN [1..data.rows] DO
PlotData[context, data, data.points[i-1], data.yMax, data.jukeboxName, data.tuneString, IF data.commandName = plotDistribution THEN ( IF i = 1 THEN "whole: coarse" ELSE "peak: fine" ) ELSE IO.PutFR["Line %d of %d", IO.int[i], IO.int[data.rows]]];
Imager.MaskRectangle[context, [x: -left, y: 0, w: left+data.width+left, h: 1]]
ENDLOOP
};
global: Data ← NIL;
Poof: ViewerEvents.EventProc = {
data: Data = NARROW[viewer.data];
IF global = data THEN global ← NIL;
};
AddPoint: PROC [currPlotPoint: INT, data: Data, value: INT] RETURNS [nextPlotPoint: INT] = {
IF currPlotPoint<0 THEN currPlotPoint𡤀
IF currPlotPoint>=data.rows*data.width THEN currPlotPoint�ta.rows*data.width-1;
data.points[currPlotPoint/data.width][currPlotPoint MOD data.width] ← value;
currPlotPoint𡤌urrPlotPoint+1; 
RETURN [IF currPlotPoint>=data.rows*data.width THEN data.rows*data.width-1 ELSE currPlotPoint]
};
LastComponentWithoutExtension: PROC [fileName: Rope.ROPE] RETURNS [simpleName: Rope.ROPE] = {
start, end: INT ← fileName.Length[] - 1;
curr: INT;
IF fileName.Fetch[start] = '> OR fileName.Fetch[start] = '/ THEN ERROR;
WHILE fileName.Fetch[start-1] # '> AND fileName.Fetch[start-1] # '/ AND start > 0 DO
start ← start - 1 ENDLOOP;
curr ← start;
IF fileName.Fetch[start] = '. OR fileName.Fetch[start] = '! THEN ERROR;
WHILE curr < end DO
IF fileName.Fetch[curr+1] = '. OR fileName.Fetch[curr+1] = '! THEN end ← curr ELSE curr ← curr + 1
ENDLOOP;
RETURN [fileName.Substr[start, end-start+1]]
};
routines used to implement PlotTune only
ParseTuneProfile: PROC [data: Data, cmd: Commander.Handle] RETURNS [succeeded: BOOLEANFALSE, error: Rope.ROPENIL] = TRUSTED {
weOpened: BOOLFALSE;
jukebox: Jukebox.Handle ← NIL;
tune: Jukebox.Tune ← NIL;
{{
ENABLE Jukebox.Error => {
error ← rope; GOTO Quit
};
argv: CommandTool.ArgumentVector;
tuneSize: INT;
tuneID: INT;
sampleIncrement: INT;
pointsPerChirp: INT;
currPlotPoint: INT ← 0;
peakEnergy: Jukebox.EnergyRange ← 0;
argv ← CommandTool.Parse[cmd ! CommandTool.Failed => {error ← errorMsg; GOTO Quit}];
IF argv.argc < 3 OR argv.argc > 4 THEN {
error ← "Usage: NewPlotTune jukeboxName|# tuneNumber [displayRows ← 8secondsPerRow]";
GOTO Quit;
};
IF argv[1].Equal["#"] THEN {
IF Jukebox.instances=NIL THEN { error ← "No jukebox open"; GOTO Quit};
jukebox ← Jukebox.instances.first;
}
ELSE jukebox ← Jukebox.FindJukebox[argv[1]];
IF jukebox = NIL THEN {
jukebox ← Jukebox.OpenJukebox[argv[1]];
weOpened ← TRUE
};
tuneID ← Convert.IntFromRope[argv[2]];
tune ← Jukebox.OpenTune[self: jukebox, tuneId: tuneID, write: FALSE];
tuneSize ← Jukebox.TuneSize[tune];
data.jukeboxName ← LastComponentWithoutExtension[jukebox.jukeboxName];
IF tuneSize = 0 THEN {error ← "Tune contains no chirps, so I won't plot them"; GOTO Quit};
data.rows ← IF argv.argc = 3 THEN MAX[(tuneSize+4)/8, 1] ELSE Convert.IntFromRope[argv[3]];
IF data.rows > maxRows THEN data.rows ← maxRows;
IF data.rows = 0 THEN {error ← "It didn't take me long to plot no rows!"; GOTO Quit};
because of the way that Tuneaccess.GetEnergyProfile works, we want to sample an integral number of times per chirp. The number of samples may not add up to a multiple of maxWidth, in which case set data.width to a lower value
sampleIncrement ← tuneSize*Jukebox.bytesPerChirp/(data.rows*maxWidth);
IF sampleIncrement > Jukebox.bytesPerChirp -- some tune: will have to add more rows !!
THEN
{ data.rows ← (tuneSize + (maxWidth -1))/maxWidth;
sampleIncrement ← Jukebox.bytesPerChirp;
IF data.rows > maxRows THEN ERROR;
data.width ← (tuneSize+(data.rows-1))/data.rows
}
ELSE
{
WHILE (Jukebox.bytesPerChirp MOD sampleIncrement) # 0 DO sampleIncrement ← sampleIncrement + 1 ENDLOOP;
data.width ← ((Jukebox.bytesPerChirp/sampleIncrement)*tuneSize+(data.rows-1))/data.rows
};
pointsPerChirp ← Jukebox.bytesPerChirp/sampleIncrement;
data.totalHeight ← data.rows*(top+data.height+bottom);
FOR i: INT IN [0..data.rows) DO
data.points[i] ← NEW [ArrayOfPoints[data.width]]
ENDLOOP;
FOR chirp: INT IN [0..tuneSize) DO
energyBlock: TuneAccess.EnergyBlock ← TuneAccess.GetEnergyProfile[jukebox, tune, chirp, , pointsPerChirp];
FOR element: INT IN [0..pointsPerChirp) DO
{ peakEnergy ← MAX [peakEnergy, energyBlock[element]];
currPlotPoint ← AddPoint[currPlotPoint: currPlotPoint, data: data, value: energyBlock[element]]
}
ENDLOOP
ENDLOOP;
IF peakEnergy = 0 THEN {error ← "No energy in this file at all!"; GOTO Quit};
the above paragraph built up the points on the graph. We want now to shade gray the points
which are silent according to the ambient level values. The Voicestreams software samples every 20ms to do this rather than at the intervals we did above, but we'll use the sampling rate above because otherwise we'll get some odd edging effects. We'll multiply all the data points by -1 if they are to be made gray, a convention that the PlotData routine understands
{ positionInChirp: INT ← 0; -- used to tell when to get a new ambient level
hangoverInPoints: INT ← Jukebox.hangoverPackets*pointsPerChirp/ INT[(Jukebox.bytesPerChirp/Jukebox.singlePktLength)];
pointsSinceLastNonSilence: INT ← hangoverInPoints;
ambientLevel: Jukebox.EnergyRange;
currChirp: INT ← 0;
FOR i: INT IN [0..data.rows) DO
FOR j: INT IN [0..data.width) DO
IF positionInChirp = 0 THEN
{
ambientLevel ← TuneAccess.ReadAmbientLevel[jukebox, tune, currChirp ! Jukebox.MissingChirp => {ambientLevel ← LAST[Jukebox.EnergyRange]; CONTINUE}; Jukebox.EOF => CONTINUE]; -- latter may occur if there are more points in the graph than pointsPerChirp*lengthInChirps due to integer division etc.
currChirp ← currChirp + 1
};
pointsSinceLastNonSilence ← IF data.points[i][j] <= ambientLevel THEN pointsSinceLastNonSilence + 1 ELSE 0;
IF pointsSinceLastNonSilence > hangoverInPoints THEN data.points[i][j] ← -data.points[i][j];
positionInChirp ← (positionInChirp+1) MOD pointsPerChirp
ENDLOOP
ENDLOOP
};
Jukebox.ArchiveCloseTune[jukebox, tune];
we use ArchiveCloseTune so that the read/write dates will not be altered by this command
IF weOpened THEN jukebox ← Jukebox.CloseJukebox[jukebox];
cmd.out.PutF["Energy peak in file is %d - graph is scaled accordingly\n", IO.int[peakEnergy]];
data.yMax ← peakEnergy;
data.tuneString ← Rope.Cat[IF tuneID < 10 THEN "Tune " ELSE "Tune", Convert.RopeFromInt[tuneID]];
data.tuneNumberFont ← IF tuneID < 100 THEN data.helvetica10 ELSE data.helvetica8;
RETURN[TRUE]
};
EXITS
Quit => {
IF tune # NIL THEN Jukebox.ArchiveCloseTune[jukebox, tune];
IF weOpened AND jukebox # NIL THEN jukebox ← Jukebox.CloseJukebox[jukebox];
RETURN [FALSE, error]
};
}};
PlotTune: Commander.CommandProc = {
data: Data ← NEW [DataRecord];
parsedOkay: BOOLEAN;
data.commandName ← plotTune;
data.height ← 60;
[parsedOkay, msg] ← ParseTuneProfile[data, cmd];
IF ~parsedOkay THEN RETURN [$Failure, msg];
data.container ← Containers.Create[[
name: "Tune Energy Time Profile",
iconic: FALSE,
icon: Icons.NewIconFromFile["JukeboxIcons.icon", 10], -- pro tem., until Polle produces a graph
column: left,
menu: NIL,
scrollable: FALSE,
data: data ]];
ViewerOps.SetOpenHeight[data.container, data.totalHeight];
data.graphs ← ViewerOps.CreateViewer[
flavor: $TunePlot,
info: [
parent: data.container,
wx: 0,
wy: 0,
ww: left+maxWidth+left, -- this means the window is still standard left-column width, even though the graph may be narrower: this is judged more visually pleasing
wh: data.totalHeight,
scrollable: FALSE,
data: data] ];
[] ← ViewerEvents.RegisterEventProc[Poof, destroy, data.graphs, TRUE];
};
routines used to implement PlotDistribution only
EnergyArray: TYPE = REF EnergyValueArray;
EnergyValueArray: TYPE = ARRAY Jukebox.EnergyRange OF INT;
ParseTuneEnergies: PROC [data: Data, cmd: Commander.Handle] RETURNS [succeeded: BOOLEANFALSE, error: Rope.ROPENIL] = TRUSTED {
weOpened: BOOLFALSE;
jukebox: Jukebox.Handle ← NIL;
tune: Jukebox.Tune ← NIL;
{{
ENABLE Jukebox.Error => {
error ← rope; GOTO Quit
};
argv: CommandTool.ArgumentVector;
tuneSize: INT;
tuneID: INT;
currPlotPoint: INT ← 0;
energyMax: Jukebox.EnergyRange ← 0;
energyPeak: Jukebox.EnergyRange ← 0;
levelsPerCoarsePixel: INT;
energyProfile: EnergyArray ← NEW [EnergyValueArray ← ALL[0]];
fineLower, fineUpper: Jukebox.EnergyRange;
argv ← CommandTool.Parse[cmd ! CommandTool.Failed => {error ← errorMsg; GOTO Quit}];
IF argv.argc # 3 THEN {
error ← "Usage: NewPlotTune jukeboxName|# tuneNumber";
GOTO Quit
};
IF argv[1].Equal["#"] THEN {
IF Jukebox.instances=NIL THEN { error ← "No jukebox open"; GOTO Quit};
jukebox ← Jukebox.instances.first;
}
ELSE jukebox ← Jukebox.FindJukebox[argv[1]];
IF jukebox = NIL THEN {
jukebox ← Jukebox.OpenJukebox[argv[1]];
weOpened ← TRUE
};
tuneID ← Convert.IntFromRope[argv[2]];
tune ← Jukebox.OpenTune[self: jukebox, tuneId: tuneID, write: FALSE];
tuneSize ← Jukebox.TuneSize[tune];
data.jukeboxName ← LastComponentWithoutExtension[jukebox.jukeboxName];
IF tuneSize = 0 THEN {error ← "Tune contains no chirps, so I won't plot them"; GOTO Quit};
the first task is to build up an array depicting the occurences of each energy level
FOR chirp: INT IN [0..tuneSize) DO
energyBlock: TuneAccess.EnergyBlock ← TuneAccess.GetEnergyProfile[jukebox, tune, chirp, , Jukebox.bytesPerChirp/Jukebox.singlePktLength];
FOR element: INT IN [0..Jukebox.bytesPerChirp/Jukebox.singlePktLength) DO
energyProfile[energyBlock[element]] ← energyProfile[energyBlock[element]] + 1
ENDLOOP
ENDLOOP;
    
we are not interested in the (potentially large) number of zero energy occurences
energyProfile[0] ← 0;
FOR i: Jukebox.EnergyRange IN Jukebox.EnergyRange DO
IF energyProfile[i] > energyProfile[energyPeak] THEN energyPeak ← i;
IF energyProfile[i] > 0 THEN energyMax ← i
ENDLOOP;
program plots out two lines of information: on the first is a histogram 0..energyMax of the energy occurences [averaged over the several levels represented by a single point on this coarse plot] and on the second a plot one level per pixel plot centred on the energyPeak, i.e. frequency maximum: the area of the second is shown gray on the first. We'll set the same y-height for both axes: on the gray region we may get against-the-rails behaviour but this doen't matter
levelsPerCoarsePixel ← ((energyMax+1) + (maxWidth-1))/maxWidth;
data.width ← (energyMax+levelsPerCoarsePixel)/levelsPerCoarsePixel;
data.rows ← 2;
data.totalHeight ← data.rows*(top+data.height+bottom);
FOR i: INT IN [0..data.rows) DO
data.points[i] ← NEW [ArrayOfPoints[data.width]]
ENDLOOP;
{ currEnergy: Jukebox.EnergyRange ← 0;
FOR i: INT IN [0..data.width-2] DO
handle data.width-1 specially in case of bounds problems
accumulator: INT ← 0;
FOR j: INT IN [0..levelsPerCoarsePixel) DO
accumulator ← accumulator + INT[energyProfile[currEnergy]];
currEnergy ← currEnergy + 1
ENDLOOP;
data.points[0][i] ← REAL[accumulator]/REAL[levelsPerCoarsePixel]
ENDLOOP;
{
lastPoint: INT ← 0;
DO lastPoint ← lastPoint + INT[energyProfile[currEnergy]]; IF currEnergy = energyMax THEN EXIT; currEnergy ← currEnergy + 1 ENDLOOP;
data.points[0][data.width-1] ← REAL[lastPoint]/REAL[levelsPerCoarsePixel]
}};
now the fine plot
SELECT INT[energyPeak] FROM
> LAST[Jukebox.EnergyRange] - (data.width+5)/6 =>
{ fineUpper ← LAST[Jukebox.EnergyRange];
fineLower ← fineUpper - data.width/3 + 1 };
< FIRST[Jukebox.EnergyRange] + (data.width+5)/6 =>
{ fineLower ← FIRST[Jukebox.EnergyRange];
fineUpper ← fineLower + data.width/3 - 1 };
ENDCASE =>
{ fineLower ← energyPeak - data.width/6;
fineUpper ← fineLower + data.width/3 - 1 };
FOR i: INT IN [0..data.width) DO
data.points[1][i] ← energyProfile[fineLower+i/3]
ENDLOOP;
and shade the fine plot regions gray on the coarse plot
FOR i: INT IN [fineLower/levelsPerCoarsePixel..fineUpper/levelsPerCoarsePixel] DO
IF i <= LAST[Jukebox.EnergyRange] THEN data.points[0][i] ← -data.points[0][i]
ENDLOOP;
Jukebox.ArchiveCloseTune[jukebox, tune];
we use ArchiveCloseTune so that the read/write dates will not be altered by this command
IF weOpened THEN jukebox ← Jukebox.CloseJukebox[jukebox];
cmd.out.PutF["Highest energy value in file is %d: this is length of coarse plot x axis\nMost frequent energy level is %d occurences of %d:\n fine plot x axis runs from %d to %d (range shown gray on coarse plot)\n", IO.int[energyMax], IO.int[energyProfile[energyPeak]], IO.int[energyPeak], IO.int[fineLower], IO.int[fineUpper]];
data.yMax ← energyProfile[energyPeak];
data.tuneString ← Rope.Cat[IF tuneID < 10 THEN "Tune " ELSE "Tune", Convert.RopeFromInt[tuneID]];
data.tuneNumberFont ← IF tuneID < 100 THEN data.helvetica10 ELSE data.helvetica8;
RETURN[TRUE]
};
EXITS
Quit => {
IF tune # NIL THEN Jukebox.ArchiveCloseTune[jukebox, tune];
IF weOpened AND jukebox # NIL THEN jukebox ← Jukebox.CloseJukebox[jukebox];
RETURN [FALSE, error]
};
}};
PlotDistribution: Commander.CommandProc = {
data: Data ← NEW [DataRecord];
parsedOkay: BOOLEAN;
data.commandName ← plotDistribution;
data.height ← 150;
[parsedOkay, msg] ← ParseTuneEnergies[data, cmd];
IF ~parsedOkay THEN RETURN [$Failure, msg];
data.container ← Containers.Create[[
name: "Tune Energy Distribution",
iconic: FALSE,
icon: Icons.NewIconFromFile["JukeboxIcons.icon", 15], -- pro tem., until Polle produces a graph icon
column: left,
menu: NIL,
scrollable: FALSE,
data: data ]];
ViewerOps.SetOpenHeight[data.container, data.totalHeight];
data.graphs ← ViewerOps.CreateViewer[
flavor: $TunePlot,
info: [
parent: data.container,
wx: 0,
wy: 0,
ww: left+maxWidth+left, -- this means the window is still standard left-column width, even though the graph may be narrower: this is judged more visually pleasing
wh: data.totalHeight,
scrollable: FALSE,
data: data] ];
[] ← ViewerEvents.RegisterEventProc[Poof, destroy, data.graphs, TRUE];
};
graphClass: ViewerClasses.ViewerClass ← NEW[ViewerClasses.ViewerClassRec ←
[paint: RepaintViewer]];
ViewerOps.RegisterViewerClass[$TunePlot, graphClass];
Commander.Register["PlotTune", PlotTune, "PlotTune jukeboxName|# tuneNumber [displayRows ← 8secondsPerRow] - plot the content of a tune in graphical form: # represents the first currently open jukebox"];
Commander.Register["PlotDistribution", PlotDistribution, "PlotDistribution jukeboxName|# tuneNumber - plot the distribution of energies in a jukebox tune: # represents the first currently open jukebox"];
}.