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