Potrzebuję napisać aplikację HTML5/JS, która będzie generować dźwięk w czasie rzeczywistym. Założenia:

  1. Aplikacja docelowo ma działać poprawnie przynajmniej w Firefox i Chrome w najnowszych wersjach na komputerze z Windows i na smartfonie z Androidem.
  2. Dźwięk ma być generowany programowo, czyli nie wchodzi w grę oscylator (doczytałem, że WebAudio ma wbudowane oscylatory).
  3. Dopuszcza się niewielkie opóźnienie odtwarzania w stosunku do generowania (najlepiej poniżej pół sekundy, maksymalnie jedna).
  4. Dźwięk ma być generowany blokami trwającymi na przykład 100ms i odtwarzany jeden za drugim.
  5. Program ma generować kolejne bloki nie od razu przed otworzeniem pierwszego, tylko w miarę postępu w odtwarzaniu wcześniej wygenerowanych, czyli w czasie odtwarzania bloku generuje tylko jeden kolejny blok, a w czasie odtwarzania tego kolejnego generuje następny i tak dalej.
  6. Dopuszcza się krótką zwłokę w reakcji na polecenia użytkownika (rozpoczęcie i zaprzestanie generowania dźwięku), zwłoka wynika z buforowania, a czas zwłoki zależy od wielkości bufora.

Doczytałem, że WebAudio przy odtwarzaniu dźwięku wywołuje zdarzenie onended wyzwalane po zakończeniu odtwarzania, nie jest wyzwalane żadne zdarzenie na rozpoczęcie odtwarzania. Natomiast, przy przygotowywaniu bloku dźwięku można bardzo dokładnie zaplanować moment rozpoczęcia odtwarzania bloku.
Wobec tego, wymyśliłem koncepcję, że na początek odtworzę dwa krótkie i ciche dźwięki (każdy trwający najwyżej 1/4 czasu bloku, opisane jako Wstep1 i Wstep2), a później już właściwe bloki (Blok1, Blok2, Blok3 itd.) według schematu, po --> napisałem, co ma zrobić w onended (dla ułatwienia zrozumienia przyjmuję, że blok trwa 1 sekundę):

  1. DźwiękWstępny1 (odtwarzanie rozpoczęte od 0.00, poleceniem użytkownika) --> Rozpocznij odtwarzanie DzwiekWstepny2 i zaplanuj Blok1 na moment czasowy 1.00 sekunda.
  2. DźwiękWstępny2 (kończy odtwarzanie w czasie poniżej sekundy 1.00) --> Zaplanuj Blok2 na 2.00
  3. Blok1 (odtwarzanie rozpoczęte w 1.00 i zakończone w 2.00) --> Zaplanuj Blok3 na 3.00
  4. Blok2 (odtwarzanie rozpoczęte w 2.00 i zakończone w 3.00) --> Zaplanuj Blok4 na 4.00
  5. Blok3 (odtwarzanie rozpoczęte w 3.00 i zakończone w 4.00) --> Zaplanuj Blok5 na 5.00
  6. Blok4 (odtwarzanie rozpoczęte w 4.00 i zakończone w 5.00) --> Zaplanuj Blok6 na 6.00
  7. Blok[N] (odtwarzanie rozpoczęte w N i zakończone w (N+1)) --> Zaplanuj Blok[N+2] na (N+2)

Przetestowałem zarówno na komputerze, jak i na smartfonie w obu przeglądarkach. Poniższy kod działa na Firefox w obu urządzeniach zgodnie z oczekiwaniem i w pełni prawidłowo, natomiast w Chrome w obu urządzeniach pomiędzy blokami dźwięku są słyszalne trzaski, a pierwszy dźwięk jest modyfikowany w trakcie odtwarzania, stąd krótki dźwięk na początku:

<!DOCTYPE html>
<html>
  <head>
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta charset="UTF-8">
  </head>
  <body>
    <input type="button" onClick="Start()" value="Start">
    <input type="button" onClick="Stop()" value="Stop">
    <br>
    <br>
    <span id="X"></span>
    <script>

// Dlugosc jednej porcji audio w sekundach
var AudioBufLengthTime = 0.25;

// Bufor dzwieku
var AudioBuffer;

// Wypisywanie tekstu
function Print(X)
{
 console.log(X);
}

// Wypisywanie tekstu
function Print2(X)
{
 document.getElementById("X").innerHTML += (X.split(" ").join(" ") + "<br>");
}


// Iterator zaladowanych porcji audio
var AudioBufIteration;


// Liczba probek w jednej porcji audio
var AudioBufLengthSample;

// Stan, czy dzwiek jest generowany i odtwarzany
var AudioWorking = false;


// Iterator kata sinusoidy
var SinAngle = 0;
var SinAngleDelta = 0.05;


// Przygotowanie dzwieku w porcji audio
function PrepareAudio()
{
    var AudioBuffer0 = AudioBuffer.getChannelData(0);
    var AudioBuffer1 = AudioBuffer.getChannelData(1);
    for (var i = 0; i < AudioBufLengthSample; i++)
    {
        AudioBuffer0[i] = Math.sin(SinAngle) * 0.1;
        AudioBuffer1[i] = Math.sin(SinAngle) * 0.1;
        SinAngle = SinAngle + SinAngleDelta;
        SinAngleDelta += 0.000002;
        if (SinAngle > (Math.PI * 2))
        {
            SinAngle = SinAngle - (Math.PI * 2);
        }
    }
}

// Kontekst audio
var audioCtx;


// Inicjalizacja audio, odtworzenie wstepnych dzwiekow w celu wymuszenia zaladowania pierwszej i drugiej porcji audio
function InitAudio()
{
    AudioBufIteration = 1;
    var AudioBuffer0 = AudioBuffer.getChannelData(0);
    var AudioBuffer1 = AudioBuffer.getChannelData(1);
    for (var i = 0; i < AudioBufLengthSample; i++)
    {
        AudioBuffer0[i] = 0;
        AudioBuffer1[i] = 0;
    }
    var source = audioCtx.createBufferSource();
    source.buffer = AudioBuffer;
    source.connect(audioCtx.destination);
    source.onended = function()
    {
        var source_ = audioCtx.createBufferSource();
        source_.buffer = AudioBuffer;
        source_.connect(audioCtx.destination);
        source_.onended = function()
        {
            GetAudio();
        }
        source_.start(0, 0, AudioBufLengthTime / 5);
        GetAudio();
    };
    AudioWorking = true;
    source.start(0, 0, AudioBufLengthTime / 5);
}


// Przygotowanie i zaladowanie porcji audio
function GetAudio()
{
    if (!AudioWorking)
    {
        return;
    }

    PrepareAudio();
    var source = audioCtx.createBufferSource();
    source.buffer = AudioBuffer;
    source.connect(audioCtx.destination);
    source.onended = function()
    {
        GetAudio();
    };
    var TimePoint = AudioBufIteration * AudioBufLengthTime;
    TimePoint = TimePoint * 1000;
    TimePoint = Math.round(TimePoint);
    TimePoint = TimePoint / 1000;
    source.start(TimePoint);
    AudioBufIteration++;

    var ProcTime = TimePoint - audioCtx.currentTime;
    Print2(TimePoint + "  GetAudio   " + ProcTime + "      " + Math.round(ProcTime * 100 / AudioBufLengthTime) + "%");
}

// Czestotliwosc probkowania
var SampleRate;

// Przycisk Start
function Start()
{
    audioCtx = new (window.AudioContext || window.webkitAudioContext)();
    SampleRate = audioCtx.sampleRate;
    AudioBufLengthSample = SampleRate * AudioBufLengthTime;
    AudioBuffer = audioCtx.createBuffer(2, AudioBufLengthSample, SampleRate);
    InitAudio();
}

// Przycisk Stop
function Stop()
{
    AudioWorking = false;
}
    </script>
  </body>
</html>

W ramach próby samodzielnego rozwiązania problemu wymyśliłem, że bufor dźwięku będzie dwa razy dłuższy niż potrzeby i co drugi blok będzie wprowadzany do bufora raz do pierwszej połowy, a raz do drugiej połowy. Wstępne dźwięki będą odtwarzane z pierwszej połowy, która jest wyzerowana, a pierwszy blok jest w drugiej połowie. Po tej modyfikacji, kod działa poprawnie w obu przeglądarkach w obu urządzeniach:

<!DOCTYPE html>
<html>
  <head>
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta charset="UTF-8">
  </head>
  <body>
    <input type="button" onClick="Start()" value="Start">
    <input type="button" onClick="Stop()" value="Stop">
    <br>
    <br>
    <span id="X"></span>
    <script>

// Dlugosc jednej porcji audio w sekundach
var AudioBufLengthTime = 0.25;

// Bufor dzwieku
var AudioBuffer;

// Wypisywanie tekstu
function Print(X)
{
 console.log(X);
}

// Wypisywanie tekstu
function Print2(X)
{
 document.getElementById("X").innerHTML += (X.split(" ").join(" ") + "<br>");
}


// Iterator zaladowanych porcji audio
var AudioBufIteration;


// Liczba probek w jednej porcji audio
var AudioBufLengthSample;

// Stan, czy dzwiek jest generowany i odtwarzany
var AudioWorking = false;


// Iterator kata sinusoidy
var SinAngle = 0;
var SinAngleDelta = 0.05;


// Okresla, czy jest uzywana druga czesc bufora
var SecondAudio;


// Przygotowanie dzwieku w porcji audio
function PrepareAudio()
{
    var AudioBuffer0 = AudioBuffer.getChannelData(0);
    var AudioBuffer1 = AudioBuffer.getChannelData(1);
    for (var i = 0; i < AudioBufLengthSample; i++)
    {
        if (SecondAudio)
        {
            AudioBuffer0[i + AudioBufLengthSample] = Math.sin(SinAngle) * 0.1;
            AudioBuffer1[i + AudioBufLengthSample] = Math.sin(SinAngle) * 0.1;
        }
        else
        {
            AudioBuffer0[i] = Math.sin(SinAngle) * 0.1;
            AudioBuffer1[i] = Math.sin(SinAngle) * 0.1;
        }
        SinAngle = SinAngle + SinAngleDelta;
        SinAngleDelta += 0.000002;
        if (SinAngle > (Math.PI * 2))
        {
            SinAngle = SinAngle - (Math.PI * 2);
        }
    }
}

// Kontekst audio
var audioCtx;


// Inicjalizacja audio, odtworzenie wstepnych dzwiekow w celu wymuszenia zaladowania pierwszej i drugiej porcji audio
function InitAudio()
{
    SecondAudio = true;
    AudioBufIteration = 1;
    var AudioBuffer0 = AudioBuffer.getChannelData(0);
    var AudioBuffer1 = AudioBuffer.getChannelData(1);
    for (var i = 0; i < AudioBufLengthSample; i++)
    {
        AudioBuffer0[i] = 0;
        AudioBuffer1[i] = 0;
    }
    var source = audioCtx.createBufferSource();
    source.buffer = AudioBuffer;
    source.connect(audioCtx.destination);
    source.onended = function()
    {
        var source_ = audioCtx.createBufferSource();
        source_.buffer = AudioBuffer;
        source_.connect(audioCtx.destination);
        source_.onended = function()
        {
            GetAudio();
        }
        source_.start(0, 0, AudioBufLengthTime / 5);
        GetAudio();
    };
    AudioWorking = true;
    source.start(0, 0, AudioBufLengthTime / 5);
}




// Przygotowanie i zaladowanie porcji audio
function GetAudio()
{
    if (!AudioWorking)
    {
        return;
    }

    PrepareAudio();
    var source = audioCtx.createBufferSource();
    source.buffer = AudioBuffer;
    source.connect(audioCtx.destination);
    source.onended = function()
    {
        GetAudio();
    };
    var TimePoint = AudioBufIteration * AudioBufLengthTime;
    TimePoint = TimePoint * 1000;
    TimePoint = Math.round(TimePoint);
    TimePoint = TimePoint / 1000;
    if (SecondAudio)
    {
        source.start(TimePoint, AudioBufLengthTime, AudioBufLengthTime);
    }
    else
    {
        source.start(TimePoint, 0, AudioBufLengthTime);
    }
    //AudioBufLengthSample
    SecondAudio = !SecondAudio;
    AudioBufIteration++;

    var ProcTime = TimePoint - audioCtx.currentTime;
    Print2(TimePoint + "  GetAudio   " + ProcTime + "      " + Math.round(ProcTime * 100 / AudioBufLengthTime) + "%");
}

// Czestotliwosc probkowania
var SampleRate;

// Przycisk Start
function Start()
{
    audioCtx = new (window.AudioContext || window.webkitAudioContext)();
    SampleRate = audioCtx.sampleRate;
    AudioBufLengthSample = SampleRate * AudioBufLengthTime;
    AudioBuffer = audioCtx.createBuffer(2, AudioBufLengthSample * 2, SampleRate);
    InitAudio();
}

// Przycisk Stop
function Stop()
{
    AudioWorking = false;
}
    </script>
  </body>
</html>

Czy może jest lepsze rozwiązanie, żeby zapewnić poprawne generowanie dźwięku we wszystkich przeglądarkach obsługujących WebAudio, inne niż podwójny bufor i odtwarzanie połowy bufora. Czy może jakiś inny obiekt powinno się do tego celu zastosować?

Punktem wyjścia był ten przykład https://developer.mozilla.org/en-US/docs/Web/API/BaseAudioContext/createBuffer który potem rozwijałem.