paulrosen / abcjs

javascript for rendering abc music notation
Other
1.86k stars 275 forks source link

beatCallback breaks when time signature numerator > denominator (e.g. 9/8)? #797

Open Esn024 opened 1 year ago

Esn024 commented 1 year ago

I tested beatCallback at various x/8 time signatures. It works fine from 2/8 up to 8/8, then breaks once the time signature becomes 9/8 - it suddenly runs at a much slower tempo than the actual playback of the music. The audio playback of the song thus ends long before the final beatCallback does. Here's an example I used that shows the effect (try console logging something within the beatCallback while simultaneously playing the song as audio playback):

const abc2 = `X:2
T:Title
C:Composer Name (yyyy)
M:9/8
Q:1/8=276
L:1/8
%%score (T1 T2)
V:T1           clef=treble  name="Left thumb"   snm="L"
V:T2           clef=treble  name="Right thumb"  snm="R"
K:Gm
%            End of header, start of tune body:
% 1
[V:T1]  zBzczdzga  | f6e2a      | (d2c2 d2)e2a | d4 c2z2a |
[V:T2]  GzAzBzeza  | d6c2a      | (B2A2 B2)c2a | B4 A2z2a |
% 5
[V:T1]  (B2c2 d2g2)a  | f8a        | d3c (d2fe)a  | H d6a    ||
[V:T2]       z8a      |     z8a    | B3A (B2c2)a  | H A6a    ||`;

This only seems to affect the beatCallback, as the eventCallback and sequenceCallback seem to work fine.

Do you know what might be going on?

I discovered that this was a problem just now when I attempted to set "Beats Per Measure" to more than "8" in the app I'm working on, abcKalimba (however, I also tested it in simpler plain-JavaScript code and got the same bug, so I don't think it's React-specific).

paulrosen commented 1 year ago

Sounds like a bug. I'll look at it. It's probably related to 9/8 being a compound meter, but 6/8 is too so I'm not sure why they aren't the same.

paulrosen commented 1 year ago

I can't reproduce this. Can you post a minimal example? It seems to work for me with both the CursorControl and TimingCallbacks.

Esn024 commented 1 year ago

I can't reproduce this. Can you post a minimal example? It seems to work for me with both the CursorControl and TimingCallbacks.

Okay, here's an HTML file. It references the standard abcjs CSS files.

The bug is reproducible by selecting "abc2", "abc3" and "abc4" by turn in the dropdown menu, then clicking the "play" button and checking the browser's console log. When the time signature is 8/8 or 7/8, the console log runs as it should, while in the 9/8 example (abc2), the console logs appear much slower than they should.

There's also a problem when selecting "abc5", although that one is 6/8. It is fine for the 6/8 portion, but when it changes to 4/4, the beatCallback doesn't adapt (I think it stays at the earlier speed). I'm not sure whether or not this is related to the other problem.

<!DOCTYPE html>
<html lang="en"><head>
    <meta charset="utf-8">
    <meta http-equiv="x-ua-compatible" content="ie=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <link rel="stylesheet" href="abcjs%20Modify%20Synth%20Input%20Demo_files/examples-styles.css">
  <link rel="stylesheet" href="abcjs%20Modify%20Synth%20Input%20Demo_files/abcjs-audio.css">

    <link rel="icon" href="https://paulrosen.github.io/abcjs/examples/favicon.ico" type="image/x-icon">
    <title>abcjs: Modify Synth Input Demo</title>

    <style>
        main {
            max-width: 770px;
            margin: 0 auto;
        }
        .abcjs-cursor {
            stroke: red;
        }
        .abcjs-rest {
            opacity: 0.1;
        }
        .color {
            stroke: red;
            fill: red;
        }
        .letter {
            text-align: center;
            margin:0;
            padding:0;
        }

    </style>

    <script src="abcjs%20Modify%20Synth%20Input%20Demo_files/abcjs-basic.js" type="text/javascript"></script>
    <script type="text/javascript">

const abc = `X:1
T: Cooley's
%%barnumbers 1
M: 8/8
L: 1/8
Q: 1/4=200
R: reel
K: Emin
EBBA B2 EB|~B2 AB dBAG|FDAD BDAD|FDAD dAFD|
EBBA B2 EB|B2 AB defg|afe^c dBAF|DEFD E2 gf|
|eB B2 efge|eB B2 gedB|A2 FA DAFA|A2 FA defg|
eB B2 eBgB|eB B2 defg|afe^c dBAF|DEFD E2 D2||`;

        const abc2 = `X:2
T:Title
C:Composer Name (yyyy)
M:9/8
Q:1/8=276
L:1/8
%%score (T1 T2)
V:T1           clef=treble  name="Left thumb"   snm="L"
V:T2           clef=treble  name="Right thumb"  snm="R"
K:Gm
%            End of header, start of tune body:
% 1
[V:T1]  zBzczdzga  | f6e2a      | (d2c2 d2)e2a | d4 c2z2a |
[V:T2]  GzAzBzeza  | d6c2a      | (B2A2 B2)c2a | B4 A2z2a |
% 5
[V:T1]  (B2c2 d2g2)a  | f8a        | d3c (d2fe)a  | H d6a    ||
[V:T2]       z8a      |     z8a    | B3A (B2c2)a  | H A6a    ||`;

const abc3 = `X:3
T:Title
C:Composer Name (yyyy)
M:8/8
Q:1/8=276
L:1/8
%%score (T1 T2)
V:T1           clef=treble  name="Left thumb"   snm="L"
V:T2           clef=treble  name="Right thumb"  snm="R"
K:Gm
%            End of header, start of tune body:
% 1
[V:T1]  zBzczdzg  | f6e2      | (d2c2 d2)e2 | d4 c2z2 |
[V:T2]  GzAzBzez  | d6c2      | (B2A2 B2)c2 | B4 A2z2 |
% 5
[V:T1]  (B2c2 d2g2)  | f8        | d3c (d2fe)  | H d6    ||
[V:T2]       z8      |     z8    | B3A (B2c2)  | H A6    ||`;

const abc4 = `X:4
T:Title
C:Composer Name (yyyy)
M:7/8
Q:1/8=276
L:1/8
%%score (T1 T2)
V:T1           clef=treble  name="Left thumb"   snm="L"
V:T2           clef=treble  name="Right thumb"  snm="R"
K:Gm
%            End of header, start of tune body:
% 1
[V:T1]  zBzczdz  | f6e      | (d2c2 d2)e | d4 c2z |
[V:T2]  GzAzBze  | d6c      | (B2A2 B2)c | B4 A2z |
% 5
[V:T1]  (B2c2 d2g)  | f7        | d3c (d2f)  | H d5    ||
[V:T2]       z7      |     z7    | B3A (B2c)  | H A5    ||
`;

const abc5 = `X:5
T:William and Nancy
T:New Mown Hay
T:Legacy, The
C:Trad.
O:England; Gloucs; Bledington % place of origin
B:Sussex Tune Book            % can be found in these books
B:Mally's Cotswold Morris vol.1 2
D:Morris On                   % can be heard on this record
P:(AB)2(AC)2A                 % play the parts in this order
M:6/8
K:G
Q:1/4=176
%%score (T1 T2)
V:T1           clef=treble  name="Left Thumb"   snm="L"
V:T2           clef=treble  name="Right Thumb"  snm="R"                        
[P:A] [V:T1] D|"G"G2G GBd|"C"e2e "G"dBG|[P:D] "D7"A2d "G"BAG|"C"E2"D7"F "G"G2:|
[V:T2] D|"G"D2D GBd|"C"e2e "G"dBG|"D7"A2d "G"BAG|"C"E2"D7"F "G"G2:|
[P:B] [V:T1] d|"G"e2d B2d|"C"gfe "G"d2d| "G"e2d    B2d|"C"gfe    "D7"d2c|
  [V:T1]        "G"B2B Bcd|"C"e2e "G"dBG|"D7"A2d "G"BAG|"C"E2"D7"F "G"G2:|
% changes of meter, using inline fields
 [V:T1] [T:Slows][M:4/4][L:1/4][P:A]"G"d2|"C"e2 "G"d2|B2 d2|"Em"gf "A7"e2|"D7"d2 "G"d2|\
  [V:T1]       "C"e2 "G"d2|[M:3/8][L:1/8] "G"B2 d |[M:6/8] "C"gfe "D7"d2c|
 [V:T1]         "G"B2B Bcd|"C"e2e "G"dBG|"D7"A2d "G"BAG|"C"E2"D7"F "G"G2:|
`;

let allAbcTunes = {abc, abc2, abc3, abc4, abc5};
    </script>
</head>
<body>
  <header>
    <h1>Modify Synth Input</h1>
  </header>
  <div class="container">
    <main>

      <label for="select-abc-tune">Select ABC tune:
        <select id="select-abc-tune" onChange="loadNewTune(allAbcTunes[this.value])">
          <option value="abc" selected>abc</option>
          <option value="abc2">abc2</option>
          <option value="abc3">abc3</option>
          <option value="abc4">abc4</option>
          <option value="abc5">abc5</option>
        </select>
      </label>
    <p>Content of the ABC tune:</p>
    <textarea id="abc-content" readonly></textarea>
      <div id="paper"></div>

        <button id="start-pause">Start</button>
        <button id="reset">Reset</button>
        <input type="range" min="0" max="100" value="0" id="slider" onChange="goToSpecificPlaceInSong(this.value / 100)">
      <div id="synth-controller"></div>
      <!-- <button class="activate-audio" style="">Activate Audio Context And Play</button>
      <button class="stop-audio" style="display:none;">Stop Audio</button> -->
      <div class="audio-error" style="display:none;">Audio is not supported in this browser.</div>

    </main>
  </div>
<script>

let currentTune = abc2;

const startPauseButtonText = ["Start", "Pause"];

let isRunning = false;
let isPaused = true;

// First draw the music - this supplies an object that has a lot of information about how to create the synth.
// NOTE: If you want just the sound without showing the music, use "*" instead of "paper" in the renderAbc call.
// initialize it with the first example tune
var visualObj = ABCJS.renderAbc("paper", currentTune, {
    responsive: "resize",
    add_classes: true
    })[0];

// function and variable to do with adding & removing the CSS that gives red color to elements that are "playing"
let lastEls = [];
const colorElements = (currentEls) => {
    let i;
    let j;
    for (i = 0; i < lastEls.length; i++) {
        for (j = 0; j < lastEls[i].length; j++) {
            lastEls[i][j].classList.remove("color");
        }
    }
    //currentEls.forEach((currentEl) => {});
    for (i = 0; i < currentEls.length; i++) {
        //console.log('currentEls[i]', currentEls[i]);
        for (j = 0; j < currentEls[i].length; j++) {
            //console.log('currentEls[i][j]', currentEls[i][j]);
            currentEls[i][j].classList.add("color");
        }
    }
    lastEls = currentEls;
}

const pauseButtonControl = () => {
    isRunning = !isRunning;
    isPaused = !isPaused;
    startPauseButton.innerText = startPauseButtonText[isRunning ? 1 : 0];
}

// the function to change colours to red
const eventCallback = (ev) => {
    if (!ev) {
        pauseButtonControl();
        return;
    }
    colorElements(ev.elements);

    //console.log('ev', ev);

    //tests
    //console.log('midiPitches', ev.midiPitches);
    //console.log('milliseconds', ev.milliseconds);
    // BUG: ev.midiPitches doesn't seem to work
    // console.log('eventCallback midiPitches', ev.midiPitches);
}

// this runs every beat
const beatCallback = async (beatNumber, totalBeats, totalTime) => {
    console.log({beatNumber});

    // array of pitches currently playing (e.g. [60, 62])
    const currentPitches = allNoteEvents.filter(e => e.beat === beatNumber).map(e => e.pitch);

    //console.log({currentPitches});

    // move the position of the audio slider
    slider.value = beatNumber/totalBeats * 100;
    if (beatNumber == totalBeats) { //tune has ended
        //console.log("piece is over");
        // loop
        await resetPlayback();
        await synth.start(0);
        timingCallbacks.start(0);
    }
}

let allNoteEvents = [];
let allPitches = [];

const resetPlayback = () => {
    isRunning = false;
    isPaused = true;
    timingCallbacks.stop();
    slider.value = 0;
    synth.stop();
    // remove any remaining red coloration
    Array.from(document.querySelectorAll('.color')).forEach((el) => el.classList.remove('color'));

    // make the text say "Start"
    startPauseButton.innerText = startPauseButtonText[0];
}

const goToSpecificPlaceInSong = async (position) => {
    //console.log({position});
    await synth.seek(position);
    timingCallbacks.setProgress(position);
}

const initializeAudio = async (visualObj) => {
    /*const synthControllerAudioParams = {
        audioContext: audioContext,
        chordsOff: true,
        soundFontUrl: "soundfonts",
        visualObj: visualObj
    }
    */
    try {
        await audioContext.resume();
            // In theory the AC shouldn't start suspended because it is being initialized in a click handler, but iOS seems to anyway.

        await synth.init({
                audioContext: audioContext,
                visualObj: visualObj,
                options: {
                    onEnded: () => { 
                        //console.log("playback ended") 
                    },
                }
            });

        await synth.prime();
        //await synth.start();
    //  await synthControl.setTune(visualObj, true, synthControllerAudioParams);

    } catch (error) {
        console.log("Audio Failed", error);
    }
}

const startPause = async () => {
    wasItPaused = isPaused;
    isRunning = !isRunning;
    isPaused = !isPaused;
    startPauseButton.innerText = startPauseButtonText[isRunning ? 1 : 0];
    if (isRunning) {
        await synth.start();
        // if playback was paused, begin from the last point, otherwise make sure it starts at the very beginning
        wasItPaused ? timingCallbacks.start() : timingCallbacks.start(0);
    } else {
        await synth.pause();
        timingCallbacks.pause();
    }
}

const loadNewTune = async (newTune) => {

    // reset currentTune
    currentTune = newTune;

    // reset array of note events and pitches
    allNoteEvents = [];
    allPitches = [];

    // reset playback (stop any ongoing animations)
    await resetPlayback();

    // display the text value of the ABC tune
    document.querySelector('#abc-content').value = newTune;

    // First draw the music - this supplies an object that has a lot of information about how to create the synth.
    // NOTE: If you want just the sound without showing the music, use "*" instead of "paper" in the renderAbc call.
    let visualObj = ABCJS.renderAbc("paper", newTune, {
        responsive: "resize",
        add_classes: true
        })[0];

    // initialize the animation's timing callbacks
    timingCallbacks = new ABCJS.TimingCallbacks(visualObj, {
        eventCallback: eventCallback,
        beatCallback: beatCallback,
    });

    initializeAudio(visualObj);

    // make the text say "Start"
    startPauseButton.innerText = startPauseButtonText[0];

}

// initialize the animation's timing callbacks
var timingCallbacks = new ABCJS.TimingCallbacks(visualObj, {
    eventCallback: eventCallback,
    beatCallback: beatCallback,
});

// check if browser supports audio, initialize audioContext
if (!ABCJS.synth.supportsAudio()) {
    const audioError = document.querySelector(".audio-error");
    audioError.setAttribute("style", "");
} else {
    // An audio context is needed - this can be passed in for two reasons:
    // 1) So that you can share this audio context with other elements on your page.
    // 2) So that you can create it during a user interaction so that the browser doesn't block the sound.
    // Setting this is optional - if you don't set an audioContext, then abcjs will create one.
    window.AudioContext = window.AudioContext ||
        window.webkitAudioContext ||
        navigator.mozAudioContext ||
        navigator.msAudioContext;

    // this is var because I need it to be in the global scope
    var audioContext = new window.AudioContext();

    // initialize synth
    var synth = new ABCJS.synth.CreateSynth();
    // initialize synth controller
}

// load the first audio + image
initializeAudio(visualObj);

// DOM elements     
const explanationDiv = document.querySelector(".suspend-explanation");
const abcTuneSelectMenu = document.getElementById("select-abc-tune");
const startPauseButton = document.getElementById("start-pause");
const resetButton = document.getElementById("reset");
const slider = document.getElementById("slider");
// reset value to 0, just in case
slider.value = 0;

// event listeners for start/pause & reset
startPauseButton.addEventListener("click", () => { startPause(); });
resetButton.addEventListener("click", () => { resetPlayback(); });
//slider.addEventListener("change", () => { goToSpecificPlaceInSong(); });

</script>

</body></html>
paulrosen commented 1 year ago

For abc5 I see what you are saying - in 6/8 there are 2 beats per measure and that works for the first part of the song. When it changes to 4/4 there are still 2 beats per measure but there should be 4.

For abc2, I still don't see the problem. In 9/8 there are 3 beats per measure and that looks like it tracks what is playing. you have 8 measures so that is 24 beats and I see the beat numbers go from 0 to 23 then go back to 0 when the tune starts over.

Do you want there to be 9 beats per measure?

Esn024 commented 1 year ago

Indeed, I had assumed that the L: field (unit note length), if it is present, would specify how many beats are intended. So, for 6/8, if L:3/8, there would be 2 beats a bar; if L:2/8 there would be 3 beats a bar, and if L:1/8, there would be 6 beats a bar. That would be my preferred behaviour - I want to tell ABCJS to do something every X interval while the music is playing, and for this to be consistent.

Then, if I want something to happen on beats 0 and 3 of every bar, there still be would be a way to do that, but I could also have something that happens on every "smaller" beat.

~It seems that at the moment, for 9/8 ABCJS assumes that there are 3 beats per measure, while for 6/8, it assumes that there are 6 beats per measure.~ EDIT: Hold on, I made the comment too soon... I see that 6/8 is indeed 2 beats/bar in the example, even if L:1/8. Sorry, I was wrong! What, then, is the logic for ABCJS to decide what the length of a beat is? I'm not sure it's specified anywhere in the documentation...

But yes, I do wish there was a simple way to tell ABCJS directly to do something every X interval while the music is playing, whether that is by means of defining what a "beat" should be with the L: field or in some other way.

paulrosen commented 1 year ago

The L: field is independent of playback. That is just for your convenience of writing music. For instance, if I'm writing something that has mostly eighth notes I'll set it to 1/8, but if the tune has mostly quarter notes I'll set it to 1/4.

Do you mean the Q: field? That would make some sense for the Q: field to override the natural counting of beats but I think that would cause a lot more confusion because there are many existing tunes with odd Q: lengths in them. That is, all 6/8 tunes would need Q:3/8=whatever or the beats would be wrong.

The logic is that there are two types of meters: simple meters where the denominator is the note that gets the beat, and compound meters (3/8, 6/8, 9/8, 12/8) where the beat is a dotted eighth.

I see what you are asking for and it is reasonable, though. I'm not sure offhand how to do that, but you could just create a timer the same way that TimingCallbacks does it with the animation timer. Or as a hack you could create a separate track of all eighth notes but make that track's volume zero and use the event callback.

The intention of the beat callback was to get a pulse at the same rate as someone would tap their foot.