diff --git a/.gitignore b/.gitignore
index 5410f6c2..1bfeb041 100644
--- a/.gitignore
+++ b/.gitignore
@@ -22,3 +22,8 @@ tsserver.log
__pycache__
.DS_Store
+.vscode
+*.aup3-shm
+*.aup3-wal
+*.aup3
+
diff --git a/public/audio/GHB/tick.mp3 b/public/audio/GHB/tick.mp3
new file mode 100644
index 00000000..8672b004
Binary files /dev/null and b/public/audio/GHB/tick.mp3 differ
diff --git a/public/audio/GHB/tick.wav b/public/audio/GHB/tick.wav
new file mode 100644
index 00000000..1a378d8c
Binary files /dev/null and b/public/audio/GHB/tick.wav differ
diff --git a/public/audio/chanter/tick.mp3 b/public/audio/chanter/tick.mp3
new file mode 100644
index 00000000..8672b004
Binary files /dev/null and b/public/audio/chanter/tick.mp3 differ
diff --git a/public/audio/chanter/tick.wav b/public/audio/chanter/tick.wav
new file mode 100644
index 00000000..1a378d8c
Binary files /dev/null and b/public/audio/chanter/tick.wav differ
diff --git a/public/images/beat-indicator-off.svg b/public/images/beat-indicator-off.svg
new file mode 100644
index 00000000..685a8830
--- /dev/null
+++ b/public/images/beat-indicator-off.svg
@@ -0,0 +1 @@
+
diff --git a/public/images/beat-indicator-on.svg b/public/images/beat-indicator-on.svg
new file mode 100644
index 00000000..8748686b
--- /dev/null
+++ b/public/images/beat-indicator-on.svg
@@ -0,0 +1 @@
+
diff --git a/public/images/play-fromselection.svg b/public/images/play-fromselection.svg
new file mode 100644
index 00000000..7579301e
--- /dev/null
+++ b/public/images/play-fromselection.svg
@@ -0,0 +1 @@
+
diff --git a/public/images/play-loopedselection.svg b/public/images/play-loopedselection.svg
new file mode 100644
index 00000000..e83ebc8f
--- /dev/null
+++ b/public/images/play-loopedselection.svg
@@ -0,0 +1 @@
+
diff --git a/public/images/play-metronome.svg b/public/images/play-metronome.svg
new file mode 100644
index 00000000..0bf3e884
--- /dev/null
+++ b/public/images/play-metronome.svg
@@ -0,0 +1 @@
+
diff --git a/public/images/stop-metronome.svg b/public/images/stop-metronome.svg
new file mode 100644
index 00000000..03050377
--- /dev/null
+++ b/public/images/stop-metronome.svg
@@ -0,0 +1 @@
+
diff --git a/src/PipeScore/Controller.ts b/src/PipeScore/Controller.ts
index 8c0ca2f4..c238648c 100644
--- a/src/PipeScore/Controller.ts
+++ b/src/PipeScore/Controller.ts
@@ -45,6 +45,8 @@ const state: State = {
playback: {
userPressedStop: false,
playing: false,
+ playingMetronome: false,
+ beatIndicator: false,
loading: true,
cursor: null,
},
@@ -136,6 +138,8 @@ function redraw() {
loggedIn: state.isLoggedIn,
loadingAudio: state.playback.loading,
isPlaying: state.playback.playing,
+ isPlayingMetronome: state.playback.playingMetronome,
+ beatIndicator: state.playback.beatIndicator,
zoomLevel: state.score.zoom,
preview: state.preview,
showingPageNumbers: state.score.showNumberOfPages,
@@ -150,9 +154,13 @@ function redraw() {
state.selection.gracenote(state.score)) ||
null,
selectedText:
- state.selection instanceof TextSelection ? state.selection.text : null,
+ state.selection instanceof TextSelection
+ ? state.selection.text
+ : null,
selectedTiming:
- state.selection instanceof TimingSelection ? state.selection.timing : null,
+ state.selection instanceof TimingSelection
+ ? state.selection.timing
+ : null,
isLandscape: state.score.landscape,
selectedStaves:
(state.selection instanceof ScoreSelection &&
diff --git a/src/PipeScore/Events/Misc.ts b/src/PipeScore/Events/Misc.ts
index d9ef61ef..5059e13a 100644
--- a/src/PipeScore/Events/Misc.ts
+++ b/src/PipeScore/Events/Misc.ts
@@ -179,6 +179,8 @@ export function exportPDF(): ScoreEvent {
userPressedStop: false,
loading: false,
cursor: null,
+ playingMetronome: false,
+ beatIndicator: false,
},
dispatch: async () => void 0,
};
@@ -293,7 +295,11 @@ export function exportPDF(): ScoreEvent {
export function download(): ScoreEvent {
return async (state: State) => {
const json = state.score.toJSON();
- saveFile(`${state.score.name()}.pipescore`, JSON.stringify(json), 'text/json');
+ saveFile(
+ `${state.score.name()}.pipescore`,
+ JSON.stringify(json),
+ 'text/json'
+ );
return Update.NoChange;
};
diff --git a/src/PipeScore/Events/Playback.ts b/src/PipeScore/Events/Playback.ts
index c10623e6..09a0c0ee 100644
--- a/src/PipeScore/Events/Playback.ts
+++ b/src/PipeScore/Events/Playback.ts
@@ -14,7 +14,7 @@
// You should have received a copy of the GNU General Public License
// along with this program. If not, see .
-import { playback } from '../Playback/impl';
+import { playback, playMetronome } from '../Playback/impl';
import { ScoreSelection } from '../Selection/score';
import type { State } from '../State';
import type { ID } from '../global/id';
@@ -68,7 +68,7 @@ export function playbackLoopingSelection(): ScoreEvent {
export function stopPlayback(): ScoreEvent {
return async (state: State) => {
- if (state.playback.playing) {
+ if (state.playback.playing || state.playback.playingMetronome) {
state.playback.userPressedStop = true;
return Update.ViewChanged;
}
@@ -109,3 +109,19 @@ export function updateInstrument(instrument: Instrument): ScoreEvent {
return Update.NoChange;
};
}
+export function startPlayMetronome(): ScoreEvent {
+ return async (state: State) => {
+ const playbackElements = state.score.play();
+ await playMetronome(state.playback);
+ return Update.NoChange;
+ };
+}
+export function updateBeatIndicator(beat: boolean): ScoreEvent {
+ return async (state: State) => {
+ if (beat !== state.playback.beatIndicator) {
+ state.playback.beatIndicator = beat;
+ return Update.ViewChanged;
+ }
+ return Update.NoChange;
+ };
+}
diff --git a/src/PipeScore/Playback/impl.ts b/src/PipeScore/Playback/impl.ts
index 4e2723c2..2d42fcf4 100644
--- a/src/PipeScore/Playback/impl.ts
+++ b/src/PipeScore/Playback/impl.ts
@@ -38,7 +38,13 @@ import {
sum,
unreachable,
} from '../global/utils';
-import { Drone, type SoundedMeasure, SoundedPitch, SoundedSilence } from './sounds';
+import {
+ Drone,
+ type SoundedMeasure,
+ SoundedPitch,
+ SoundedSilence,
+ Tick,
+} from './sounds';
import type { PlaybackState } from './state';
function shouldDeleteBecauseOfSecondTimings(
@@ -206,7 +212,9 @@ function expandRepeats(
}
// Append the item to the current measure/part in the output
- if (!shouldDeleteBecauseOfSecondTimings(inputIndex, timings, repeating)) {
+ if (
+ !shouldDeleteBecauseOfSecondTimings(inputIndex, timings, repeating)
+ ) {
nlast(output).parts[partIndex].push(item);
}
@@ -236,7 +244,8 @@ function expandRepeats(
repeatStartIndex = measureIndex;
} else if (measure.repeatEnd && measureIndex > repeatEndIndex) {
// If the measure has an end repeat, then set measureIndex back to repeatStartIndex
- timingOverRepeat = timings.find((t) => t.in(inputIndexAfterMeasure)) || null;
+ timingOverRepeat =
+ timings.find((t) => t.in(inputIndexAfterMeasure)) || null;
repeatEndIndex = measureIndex;
// Go back to repeat
measureIndex = repeatStartIndex - 1;
@@ -300,7 +309,9 @@ function getSoundedPitches(
switch (e.type) {
case 'note': {
const duration = e.duration - currentGracenoteDuration;
- soundedPart.push(new SoundedPitch(e.pitch, duration, ctx, currentID));
+ soundedPart.push(
+ new SoundedPitch(e.pitch, duration, ctx, currentID)
+ );
currentGracenoteDuration = 0;
break;
}
@@ -371,10 +382,18 @@ async function playPitches(
end: ID | null,
loop: boolean
) {
- const measuresToPlay = getSoundedPitches(measures, timings, context, start, end);
+ const measuresToPlay = getSoundedPitches(
+ measures,
+ timings,
+ context,
+ start,
+ end
+ );
const numberOfItems = sum(
- measuresToPlay.flatMap((measure) => measure.parts.flatMap((part) => part.length))
+ measuresToPlay.flatMap((measure) =>
+ measure.parts.flatMap((part) => part.length)
+ )
);
if (numberOfItems === 0) {
@@ -407,3 +426,21 @@ async function playPitches(
state.userPressedStop = false;
dispatch(updateView());
}
+export async function playMetronome(state: PlaybackState) {
+ if (state.playing || state.loading) return;
+
+ const context = new AudioContext();
+ const tick = new Tick(context);
+ state.playingMetronome = true;
+
+ tick.start();
+ while (true) {
+ // Loop until user presses stop
+ await sleep(1000);
+ if (state.userPressedStop) break;
+ }
+ tick.stop();
+ state.userPressedStop = false;
+ state.playingMetronome = false;
+ dispatch(updateView());
+}
diff --git a/src/PipeScore/Playback/index.ts b/src/PipeScore/Playback/index.ts
index 87aebde6..27098a29 100644
--- a/src/PipeScore/Playback/index.ts
+++ b/src/PipeScore/Playback/index.ts
@@ -49,8 +49,15 @@ export type PlaybackGracenote = {
* @param children playback items that go between the object start and object end
* @returns updated list of PlaybackItems, including object
*/
-export function playbackObject(id: ID, children: PlaybackItem[]): PlaybackItem[] {
- return [{ type: 'object-start', id }, ...children, { type: 'object-end', id }];
+export function playbackObject(
+ id: ID,
+ children: PlaybackItem[]
+): PlaybackItem[] {
+ return [
+ { type: 'object-start', id },
+ ...children,
+ { type: 'object-end', id },
+ ];
}
/**
@@ -100,7 +107,11 @@ export class PlaybackMeasure {
public repeatStart: boolean;
public repeatEnd: boolean;
- constructor(items: PlaybackItem[][], repeatStart: boolean, repeatEnd: boolean) {
+ constructor(
+ items: PlaybackItem[][],
+ repeatStart: boolean,
+ repeatEnd: boolean
+ ) {
this.parts = items;
this.repeatStart = repeatStart;
this.repeatEnd = repeatEnd;
diff --git a/src/PipeScore/Playback/resources.ts b/src/PipeScore/Playback/resources.ts
index aa60a06c..05ed8d17 100644
--- a/src/PipeScore/Playback/resources.ts
+++ b/src/PipeScore/Playback/resources.ts
@@ -38,6 +38,7 @@ type InstrumentResources = {
highg: AudioResource;
higha: AudioResource;
drones: AudioResource | null;
+ tick: AudioResource;
};
const ghb: InstrumentResources = {
@@ -51,6 +52,7 @@ const ghb: InstrumentResources = {
highg: new AudioResource('GHB/highg'),
higha: new AudioResource('GHB/higha'),
drones: new AudioResource('GHB/drones'),
+ tick: new AudioResource('GHB/tick'),
};
const chanter: InstrumentResources = {
@@ -64,6 +66,7 @@ const chanter: InstrumentResources = {
highg: new AudioResource('chanter/highg'),
higha: new AudioResource('chanter/higha'),
drones: null,
+ tick: new AudioResource('chanter/tick'),
};
/**
@@ -87,6 +90,7 @@ function loadInstrumentResources(
resources.highg.load(context),
resources.higha.load(context),
resources.drones?.load(context),
+ resources.tick.load(context),
]);
}
@@ -98,8 +102,8 @@ export function getInstrumentResources(): InstrumentResources {
return settings.instrument === Instrument.Chanter
? chanter
: settings.instrument === Instrument.GHB
- ? ghb
- : unreachable(settings.instrument);
+ ? ghb
+ : unreachable(settings.instrument);
}
// This is in a function (rather than at the top level)
diff --git a/src/PipeScore/Playback/sounds.ts b/src/PipeScore/Playback/sounds.ts
index 10dbf0e7..4a06b2bb 100644
--- a/src/PipeScore/Playback/sounds.ts
+++ b/src/PipeScore/Playback/sounds.ts
@@ -17,7 +17,7 @@
// Drone and SoundedPitch classes enable playback of drones and notes (including gracenotes).
import { dispatch } from '../Controller';
-import { updatePlaybackCursor } from '../Events/Playback';
+import { updateBeatIndicator, updatePlaybackCursor } from '../Events/Playback';
import type { ID } from '../global/id';
import type { Pitch } from '../global/pitch';
import { settings } from '../global/settings';
@@ -59,6 +59,46 @@ export class Drone {
}
}
+/**
+/**
+ * Metronome tick playback.
+ */
+export class Tick {
+ private sample: Sample;
+ private stopped = false;
+
+ constructor(context: AudioContext) {
+ const tick = getInstrumentResources().tick;
+ this.sample = tick && new Sample(tick, context);
+ }
+
+ /**
+ * Start the metronome tick, looping forever until .stop() is called.
+ */
+ async start() {
+ const beatIndicatorDuration = 200; // duration of beat indicator on UI in ms
+ const tickLeadInDuration = 150; // Aligns the centre of the audio tick to the beat indicator in ms
+ while (!this.stopped) {
+ const duration = (1000 * 60) / settings.bpm;
+ this.sample.start(1);
+ await sleep(tickLeadInDuration);
+ dispatch(updateBeatIndicator(true));
+ await sleep(beatIndicatorDuration);
+ dispatch(updateBeatIndicator(false));
+ await sleep(duration - beatIndicatorDuration - tickLeadInDuration);
+ }
+ }
+
+ /**
+ * Stop the tick.
+ */
+ stop() {
+ if (this.sample) {
+ this.sample.stop();
+ }
+ this.stopped = true;
+ }
+}
/**
* Pitched note playback (used for notes and gracenotes).
*/
@@ -77,7 +117,12 @@ export class SoundedPitch {
// see SoundedSilence for details.
public durationIncludingTies: number;
- constructor(pitch: Pitch, duration: number, ctx: AudioContext, id: ID | null) {
+ constructor(
+ pitch: Pitch,
+ duration: number,
+ ctx: AudioContext,
+ id: ID | null
+ ) {
this.sample = new Sample(pitchToAudioResource(pitch), ctx);
this.pitch = pitch;
this.duration = duration;
diff --git a/src/PipeScore/Playback/state.ts b/src/PipeScore/Playback/state.ts
index 318dc799..b4c1afa5 100644
--- a/src/PipeScore/Playback/state.ts
+++ b/src/PipeScore/Playback/state.ts
@@ -19,6 +19,8 @@ import type { ID } from '../global/id';
export type PlaybackState = {
userPressedStop: boolean;
playing: boolean;
+ playingMetronome: boolean;
+ beatIndicator: boolean;
loading: boolean;
// Location of playback cursor
cursor: ID | null;
diff --git a/src/PipeScore/Translations/English.ts b/src/PipeScore/Translations/English.ts
index d6b2b691..c4581edc 100644
--- a/src/PipeScore/Translations/English.ts
+++ b/src/PipeScore/Translations/English.ts
@@ -115,9 +115,13 @@ export const EnglishDocumentation: Documentation = {
'Play a preview of the score, starting at the currently selected note/bar. This will only work once the samples are downloaded (if the samples need to download, you will see a notice).',
'play-looping-selection':
'Play the currently selected part of the score, repeating forever.',
+ 'play-metronome': 'Start the metronome',
+ 'stop-metronome': 'Stop the metronome',
+ beatindicator: 'Displays the beat of the metronome',
stop: 'Stop the playback.',
'playback-speed': 'Control the playback speed (further right is faster).',
- 'harmony-volume': 'Control how loud the harmony plays (further right is louder).',
+ 'harmony-volume':
+ 'Control how loud the harmony plays (further right is louder).',
export: 'Export the score to a PDF file, that may then be shared or printed.',
'export-bww':
"Export the score to a BWW file, that may be opened in other applications. This is currently very new, and won't work for most scores.",
@@ -203,6 +207,7 @@ export const EnglishTextItems: TextItems = {
playFromBeginning: 'Play from Beginning',
playFromSelection: 'Play from Selection',
playLoopedSelection: 'Play looped Selection',
+ playMetronome: 'Play Metronome',
stop: 'Stop',
playbackOptions: 'Playback Options',
beatsPerMinute: 'beats per minute',
diff --git a/src/PipeScore/Translations/French.ts b/src/PipeScore/Translations/French.ts
index ddbab4cc..4091af86 100644
--- a/src/PipeScore/Translations/French.ts
+++ b/src/PipeScore/Translations/French.ts
@@ -119,10 +119,14 @@ export const FrenchDocumentation: Documentation = {
"Jouer un aperçu de la partition à partir de la note/mesure sélectionnée. Cela ne fonctionnera qu'une fois les échantillons téléchargés (si les échantillons doivent être téléchargés, vous verrez un avis).",
'play-looping-selection':
'Jouer la partie de la partition actuellement sélectionnée, en jouant en boucle.',
+ 'play-metronome': 'Démarrer le métronome',
+ 'stop-metronome': 'Arrêter le métronome',
+ beatindicator: 'Affiche le battement du métronome',
stop: 'Arrêter la lecture.',
'playback-speed':
"Contrôler la vitesse de lecture (plus c'est à droite, plus c'est rapide).",
- 'harmony-volume': 'Control how loud the harmony plays (further right is louder).',
+ 'harmony-volume':
+ 'Control how loud the harmony plays (further right is louder).',
export:
'Exporter la partition vers un fichier PDF, qui peut ensuite être partagé ou imprimé.',
'export-bww':
@@ -140,7 +144,8 @@ export const FrenchDocumentation: Documentation = {
"Déplacez la mesure sélectionnée à la fin de la portée précédente. Ceci ne s'applique que si vous êtes en train de sélectionner la première mesure d'une portée.",
'move-bar-to-next-line':
"Déplacer la mesure sélectionnée au début de la portée suivante. Ceci ne s'applique que si vous êtes en train de sélectionner la dernière mesure d'une portée.",
- 'nothing-hovered': "Survolez les différentes icônes pour afficher l'aide ici.",
+ 'nothing-hovered':
+ "Survolez les différentes icônes pour afficher l'aide ici.",
};
export const FrenchTextItems: TextItems = {
@@ -209,6 +214,7 @@ export const FrenchTextItems: TextItems = {
playFromBeginning: 'Jouer du Début',
playFromSelection: 'Jouer de la Sélection',
playLoopedSelection: 'Jouer Sélection en Boucle',
+ playMetronome: 'Jouer au métronome',
stop: 'Arrêter',
playbackOptions: 'Playback Options',
beatsPerMinute: 'battements par minute',
diff --git a/src/PipeScore/Translations/index.ts b/src/PipeScore/Translations/index.ts
index c3fc20bc..f3c378f2 100644
--- a/src/PipeScore/Translations/index.ts
+++ b/src/PipeScore/Translations/index.ts
@@ -82,6 +82,8 @@ export type Documentation = {
play: string;
'play-from-selection': string;
'play-looping-selection': string;
+ 'play-metronome': string;
+ 'stop-metronome': string;
stop: string;
'playback-speed': string;
'harmony-volume': string;
@@ -97,6 +99,7 @@ export type Documentation = {
'move-bar-to-previous-line': string;
'move-bar-to-next-line': string;
'nothing-hovered': string;
+ beatindicator: string;
};
export type TextItems = {
@@ -165,6 +168,7 @@ export type TextItems = {
playFromBeginning: string;
playFromSelection: string;
playLoopedSelection: string;
+ playMetronome: string;
stop: string;
playbackOptions: string;
beatsPerMinute: string;
diff --git a/src/PipeScore/UI/view.ts b/src/PipeScore/UI/view.ts
index 597f7a8a..761da76a 100644
--- a/src/PipeScore/UI/view.ts
+++ b/src/PipeScore/UI/view.ts
@@ -59,6 +59,7 @@ import {
setPlaybackBpm,
startPlayback,
startPlaybackAtSelection,
+ startPlayMetronome,
stopPlayback,
updateInstrument,
} from '../Events/Playback';
@@ -72,8 +73,18 @@ import {
resetStaveGap,
setStaveGap,
} from '../Events/Stave';
-import { addText, centreText, editText, setTextX, setTextY } from '../Events/Text';
-import { addSecondTiming, addSingleTiming, editTimingText } from '../Events/Timing';
+import {
+ addText,
+ centreText,
+ editText,
+ setTextX,
+ setTextY,
+} from '../Events/Text';
+import {
+ addSecondTiming,
+ addSingleTiming,
+ editTimingText,
+} from '../Events/Timing';
import { addTune, deleteTune, resetTuneGap, setTuneGap } from '../Events/Tune';
import type { IGracenote } from '../Gracenote';
import type { IMeasure } from '../Measure';
@@ -108,6 +119,8 @@ export interface UIState {
loggedIn: boolean;
loadingAudio: boolean;
isPlaying: boolean;
+ isPlayingMetronome: boolean;
+ beatIndicator: boolean;
selectedGracenote: IGracenote | null;
selectedStaves: IStave[];
selectedMeasures: IMeasure[];
@@ -439,7 +452,8 @@ export default function render(state: UIState): m.Children {
disabled: !barsSelected,
class: startBarClass(Barline.normal),
style: 'margin-left: .5rem;',
- onclick: () => state.dispatch(setBarline('start', Barline.normal)),
+ onclick: () =>
+ state.dispatch(setBarline('start', Barline.normal)),
},
text('normalBarline')
),
@@ -452,7 +466,8 @@ export default function render(state: UIState): m.Children {
{
disabled: !barsSelected,
class: startBarClass(Barline.repeat),
- onclick: () => state.dispatch(setBarline('start', Barline.repeat)),
+ onclick: () =>
+ state.dispatch(setBarline('start', Barline.repeat)),
},
text('repeatBarline')
),
@@ -465,7 +480,8 @@ export default function render(state: UIState): m.Children {
{
disabled: !barsSelected,
class: startBarClass(Barline.part),
- onclick: () => state.dispatch(setBarline('start', Barline.part)),
+ onclick: () =>
+ state.dispatch(setBarline('start', Barline.part)),
},
text('partBarline')
),
@@ -482,7 +498,8 @@ export default function render(state: UIState): m.Children {
disabled: !barsSelected,
class: endBarClass(Barline.normal),
style: 'margin-left: .5rem;',
- onclick: () => state.dispatch(setBarline('end', Barline.normal)),
+ onclick: () =>
+ state.dispatch(setBarline('end', Barline.normal)),
},
text('normalBarline')
),
@@ -495,7 +512,8 @@ export default function render(state: UIState): m.Children {
{
disabled: !barsSelected,
class: endBarClass(Barline.repeat),
- onclick: () => state.dispatch(setBarline('end', Barline.repeat)),
+ onclick: () =>
+ state.dispatch(setBarline('end', Barline.repeat)),
},
text('repeatBarline')
),
@@ -852,7 +870,10 @@ export default function render(state: UIState): m.Children {
'edit-text',
m(
'button.double-width.text',
- { disabled: !textSelected, onclick: () => state.dispatch(editText()) },
+ {
+ disabled: !textSelected,
+ onclick: () => state.dispatch(editText()),
+ },
text('editText')
),
state.dispatch
@@ -920,50 +941,61 @@ export default function render(state: UIState): m.Children {
m('div.section-content', [
help(
'play',
- m(
- 'button.double-width.text',
- {
- disabled: state.isPlaying,
- onclick: () => state.dispatch(startPlayback()),
- },
- text('playFromBeginning')
- ),
+ m('button', {
+ disabled: state.isPlaying || state.isPlayingMetronome,
+ onclick: () => state.dispatch(startPlayback()),
+ class: 'play-button',
+ }),
state.dispatch
),
help(
'play-from-selection',
- m(
- 'button.double-width.text',
- {
- disabled: state.isPlaying || !barsSelected,
- onclick: () => state.dispatch(startPlaybackAtSelection()),
- },
- text('playFromSelection')
- ),
+ m('button', {
+ disabled:
+ state.isPlaying || !barsSelected || state.isPlayingMetronome,
+ onclick: () => state.dispatch(startPlaybackAtSelection()),
+ class: 'play-fromselection',
+ }),
state.dispatch
),
help(
'play-looping-selection',
- m(
- 'button.double-width.text',
- {
- disabled: state.isPlaying || state.selectedNotes.length === 0,
- onclick: () => state.dispatch(playbackLoopingSelection()),
- },
- text('playLoopedSelection')
- ),
+ m('button', {
+ disabled:
+ state.isPlaying ||
+ state.selectedNotes.length === 0 ||
+ state.isPlayingMetronome,
+ onclick: () => state.dispatch(playbackLoopingSelection()),
+ class: 'play-loopedselection',
+ }),
+ state.dispatch
+ ),
+ help(
+ state.isPlayingMetronome ? 'stop' : 'play-metronome',
+ m('button', {
+ disabled: state.isPlaying || state.isPlayingMetronome,
+ onclick: () => state.dispatch(startPlayMetronome()),
+ class: 'play-metronome',
+ }),
+ state.dispatch
+ ),
+ help(
+ 'beatindicator',
+ m('button', {
+ disabled: !state.isPlayingMetronome,
+ class: state.beatIndicator
+ ? 'beat-indicator-on'
+ : 'beat-indicator-off',
+ }),
state.dispatch
),
help(
'stop',
- m(
- 'button',
- {
- disabled: !state.isPlaying,
- onclick: () => state.dispatch(stopPlayback()),
- },
- text('stop')
- ),
+ m('button', {
+ disabled: !state.isPlaying && !state.isPlayingMetronome,
+ onclick: () => state.dispatch(stopPlayback()),
+ class: 'stop-button',
+ }),
state.dispatch
),
]),
@@ -1024,32 +1056,35 @@ export default function render(state: UIState): m.Children {
]),
]),
m('section', [
- m('h2', text('instrument')),
+ m('div.section-content.vertical', [
+ m('h2', text('instrument')),
- m(
- 'label',
- m('input', {
- type: 'radio',
- name: 'instrument',
- disabled: state.isPlaying,
- checked: settings.instrument === Instrument.GHB,
- onchange: () => state.dispatch(updateInstrument(Instrument.GHB)),
- value: '',
- }),
- text('instrumentPipes')
- ),
- m(
- 'label',
- m('input', {
- type: 'radio',
- name: 'instrument',
- disabled: state.isPlaying,
- checked: settings.instrument === Instrument.Chanter,
- onchange: () => state.dispatch(updateInstrument(Instrument.Chanter)),
- value: 'pc',
- }),
- text('instrumentPC')
- ),
+ m(
+ 'label',
+ m('input', {
+ type: 'radio',
+ name: 'instrument',
+ disabled: state.isPlaying,
+ checked: settings.instrument === Instrument.GHB,
+ onchange: () => state.dispatch(updateInstrument(Instrument.GHB)),
+ value: '',
+ }),
+ text('instrumentPipes')
+ ),
+ m(
+ 'label',
+ m('input', {
+ type: 'radio',
+ name: 'instrument',
+ disabled: state.isPlaying,
+ checked: settings.instrument === Instrument.Chanter,
+ onchange: () =>
+ state.dispatch(updateInstrument(Instrument.Chanter)),
+ value: 'pc',
+ }),
+ text('instrumentPC')
+ ),
+ ]),
]),
];
@@ -1062,7 +1097,9 @@ export default function render(state: UIState): m.Children {
m(
'button',
{
- class: `text double-width ${state.isLandscape ? ' highlighted' : ''}`,
+ class: `text double-width ${
+ state.isLandscape ? ' highlighted' : ''
+ }`,
onclick: () => state.dispatch(landscape()),
},
text('landscape')
@@ -1074,7 +1111,9 @@ export default function render(state: UIState): m.Children {
m(
'button',
{
- class: `text double-width ${state.isLandscape ? '' : ' highlighted'}`,
+ class: `text double-width ${
+ state.isLandscape ? '' : ' highlighted'
+ }`,
onclick: () => state.dispatch(portrait()),
},
text('portrait')
@@ -1183,7 +1222,8 @@ export default function render(state: UIState): m.Children {
document: documentMenu,
};
- const menuClass = (s: Menu): string => (s === state.currentMenu ? 'selected' : '');
+ const menuClass = (s: Menu): string =>
+ s === state.currentMenu ? 'selected' : '';
const loginWarning = [
'You are currently not logged in. Any changes you make will not be saved. ',
@@ -1197,7 +1237,8 @@ export default function render(state: UIState): m.Children {
];
const showLoginWarning = state.canEdit && !state.loggedIn;
const showOtherUsersScoreWarning = !state.canEdit;
- const showAudioWarning = state.loadingAudio && state.currentMenu === 'playback';
+ const showAudioWarning =
+ state.loadingAudio && state.currentMenu === 'playback';
const warning = [
...(showLoginWarning ? loginWarning : []),
...(showOtherUsersScoreWarning ? otherUsersScoreWarning : []),
@@ -1238,7 +1279,10 @@ export default function render(state: UIState): m.Children {
menuHead('settings', text('settingsMenu')),
help(
'help',
- m('button', m('a[href=/help]', { target: '_blank' }, text('helpMenu'))),
+ m(
+ 'button',
+ m('a[href=/help]', { target: '_blank' }, text('helpMenu'))
+ ),
state.dispatch
),
m(
@@ -1272,7 +1316,10 @@ export default function render(state: UIState): m.Children {
'save',
m(
'button.save',
- { disabled: state.saved, onclick: () => state.dispatch(save()) },
+ {
+ disabled: state.saved,
+ onclick: () => state.dispatch(save()),
+ },
text('save')
),
state.dispatch
@@ -1369,18 +1416,19 @@ function mobileView(state: UIState): m.Children {
m('section', [
m(
'div.section-content',
- { class: state.isPlaying ? 'play-button' : 'stop-button' },
+ { class: state.isPlaying ? 'stop-button' : 'play-button' },
[
help(
state.isPlaying ? 'stop' : 'play',
m('button', {
+ disabled: state.isPlayingMetronome,
onclick: () =>
state.dispatch(
state.isPlaying
? stopPlayback()
: state.selectedTune === null
- ? startPlayback()
- : startPlaybackAtSelection()
+ ? startPlayback()
+ : startPlaybackAtSelection()
),
class: state.isPlaying ? 'stop-button' : 'play-button',
}),
@@ -1389,6 +1437,52 @@ function mobileView(state: UIState): m.Children {
]
),
]),
+ m(
+ 'div.section-content',
+ {
+ class: state.isPlayingMetronome
+ ? 'stop-metronome'
+ : 'play-metronome',
+ },
+ [
+ help(
+ state.isPlaying ? 'stop-metronome' : 'play-metronome',
+ m('button', {
+ disabled: state.isPlaying,
+ onclick: () =>
+ state.dispatch(
+ state.isPlayingMetronome
+ ? stopPlayback()
+ : startPlayMetronome()
+ ),
+ class: state.isPlayingMetronome
+ ? 'stop-metronome'
+ : 'play-metronome',
+ }),
+ state.dispatch
+ ),
+ ]
+ ),
+ m(
+ 'div.section-content',
+ {
+ class: state.beatIndicator
+ ? 'beat-indicator-on'
+ : 'beat-indicator-off',
+ },
+ [
+ help(
+ 'beatindicator',
+ m('button', {
+ disabled: !state.isPlayingMetronome,
+ class: state.beatIndicator
+ ? 'beat-indicator-on'
+ : 'beat-indicator-off',
+ }),
+ state.dispatch
+ ),
+ ]
+ ),
m('div.section-content', [
m('input', {
type: 'range',
@@ -1420,9 +1514,9 @@ function mobileView(state: UIState): m.Children {
]),
state.dispatch
),
- m('section', [
- m('h2', text('instrument')),
-
+ ]),
+ m('div.section-content', [
+ m('div.section-content.vertical', [
m(
'label',
m('input', {
@@ -1430,11 +1524,14 @@ function mobileView(state: UIState): m.Children {
name: 'instrument',
disabled: state.isPlaying,
checked: settings.instrument === Instrument.GHB,
- onchange: () => state.dispatch(updateInstrument(Instrument.GHB)),
+ onchange: () =>
+ state.dispatch(updateInstrument(Instrument.GHB)),
value: '',
}),
text('instrumentPipes')
),
+ ]),
+ m('div.section-content.vertical', [
m(
'label',
m('input', {
@@ -1442,7 +1539,8 @@ function mobileView(state: UIState): m.Children {
name: 'instrument',
disabled: state.isPlaying,
checked: settings.instrument === Instrument.Chanter,
- onchange: () => state.dispatch(updateInstrument(Instrument.Chanter)),
+ onchange: () =>
+ state.dispatch(updateInstrument(Instrument.Chanter)),
value: 'pc',
}),
text('instrumentPC')
diff --git a/src/PipeScore/tests/events/common.ts b/src/PipeScore/tests/events/common.ts
index 8f98881f..b1fe5c5f 100644
--- a/src/PipeScore/tests/events/common.ts
+++ b/src/PipeScore/tests/events/common.ts
@@ -19,6 +19,8 @@ export function emptyState(score: IScore = Score.blank()): State {
loading: false,
userPressedStop: false,
cursor: null,
+ playingMetronome: false,
+ beatIndicator: false,
},
score,
};
diff --git a/src/styles/pipescore.scss b/src/styles/pipescore.scss
index f0213464..f1ad9f77 100644
--- a/src/styles/pipescore.scss
+++ b/src/styles/pipescore.scss
@@ -393,3 +393,27 @@ label.text-coord {
.stop-button {
background-image: url('../images/stop.svg');
}
+
+.play-metronome {
+ background-image: url('../images/play-metronome.svg');
+}
+
+.stop-metronome {
+ background-image: url('../images/stop-metronome.svg');
+}
+
+.play-fromselection {
+ background-image: url('../images/play-fromselection.svg');
+}
+
+.play-loopedselection {
+ background-image: url('../images/play-loopedselection.svg');
+}
+
+.beat-indicator-on {
+ background-image: url('../images/beat-indicator-on.svg');
+}
+
+.beat-indicator-off {
+ background-image: url('../images/beat-indicator-off.svg');
+}
diff --git a/todo.md b/todo.md
index b918a7c5..477b8e65 100644
--- a/todo.md
+++ b/todo.md
@@ -14,10 +14,10 @@
- [ ] Note in docs about keyboard-based
- [x] Fix harmony playback
- [ ] Importing tunes into other scores
-- [ ] Count ins
+- [ ] Count ins / Quick march attack and Slow march attack
- [ ] Password change
- [ ] Chanter playback
-- [ ] Metronome while playing
+- [x] Metronome while playing
## Bugs to fix