mirror of
https://github.com/make-42/hayai.git
synced 2025-01-18 18:47:10 +01:00
386 lines
9.8 KiB
Go
386 lines
9.8 KiB
Go
// Copyright 2015 Hajime Hoshi
|
|
//
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
// you may not use this file except in compliance with the License.
|
|
// You may obtain a copy of the License at
|
|
//
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing, software
|
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
// See the License for the specific language governing permissions and
|
|
// limitations under the License.
|
|
|
|
// +build js
|
|
|
|
package oto
|
|
|
|
import (
|
|
"encoding/base64"
|
|
"errors"
|
|
"fmt"
|
|
"sync"
|
|
"syscall/js"
|
|
)
|
|
|
|
type driver struct {
|
|
sampleRate int
|
|
channelNum int
|
|
bitDepthInBytes int
|
|
nextPos float64
|
|
tmp []byte
|
|
bufferSize int
|
|
context js.Value
|
|
ready bool
|
|
callbacks map[string]js.Func
|
|
|
|
// For Audio Worklet
|
|
workletNode js.Value
|
|
workletNodePost js.Value
|
|
messageArray js.Value
|
|
transferArray js.Value
|
|
bufs [][]js.Value
|
|
cond *sync.Cond
|
|
}
|
|
|
|
type warn struct {
|
|
msg string
|
|
}
|
|
|
|
func (w *warn) Error() string {
|
|
return w.msg
|
|
}
|
|
|
|
const audioBufferSamples = 3200
|
|
|
|
func tryAudioWorklet(context js.Value, channelNum int) (js.Value, error) {
|
|
if !js.Global().Get("AudioWorkletNode").Truthy() {
|
|
return js.Undefined(), nil
|
|
}
|
|
|
|
worklet := context.Get("audioWorklet")
|
|
if !worklet.Truthy() {
|
|
return js.Undefined(), &warn{
|
|
msg: "AudioWorklet is not available due to the insecure context. See https://developer.mozilla.org/en-US/docs/Web/API/AudioWorklet",
|
|
}
|
|
}
|
|
|
|
script := `
|
|
class EbitenAudioWorkletProcessor extends AudioWorkletProcessor {
|
|
constructor() {
|
|
super();
|
|
|
|
this.buffers_ = [[], []];
|
|
this.offsets_ = [0, 0];
|
|
this.offsetsInArray_ = [0, 0];
|
|
this.consumed_ = [];
|
|
|
|
this.port.onmessage = (e) => {
|
|
const bufs = e.data;
|
|
for (let ch = 0; ch < bufs.length; ch++) {
|
|
const buf = bufs[ch];
|
|
this.buffers_[ch].push(new Float32Array(buf.buffer, buf.byteOffset, buf.byteLength / 4));
|
|
}
|
|
};
|
|
}
|
|
|
|
bufferTotalLength(ch) {
|
|
const sum = this.buffers_[ch].reduce((total, buf) => total + buf.length, 0);
|
|
return sum - this.offsetsInArray_[ch];
|
|
}
|
|
|
|
consume(ch, i) {
|
|
while (this.buffers_[ch][0].length <= i - this.offsets_[ch]) {
|
|
this.offsets_[ch] += this.buffers_[ch][0].length;
|
|
this.offsetsInArray_[ch] = 0;
|
|
const buf = this.buffers_[ch].shift();
|
|
this.appendConsumedBuffer(ch, buf);
|
|
}
|
|
this.offsetsInArray_[ch]++;
|
|
return this.buffers_[ch][0][i - this.offsets_[ch]];
|
|
}
|
|
|
|
appendConsumedBuffer(ch, buf) {
|
|
let idx = this.consumed_.length - 1;
|
|
if (idx < 0 || this.consumed_[idx][ch]) {
|
|
this.consumed_.push([]);
|
|
idx++;
|
|
}
|
|
this.consumed_[idx][ch] = new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength);
|
|
}
|
|
|
|
process(inputs, outputs, parameters) {
|
|
const out = outputs[0];
|
|
|
|
if (this.bufferTotalLength(0) < out[0].length) {
|
|
for (let ch = 0; ch < out.length; ch++) {
|
|
for (let i = 0; i < out[ch].length; i++) {
|
|
out[ch][i] = 0;
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
for (let ch = 0; ch < out.length; ch++) {
|
|
const offset = this.offsets_[ch] + this.offsetsInArray_[ch];
|
|
for (let i = 0; i < out[ch].length; i++) {
|
|
out[ch][i] = this.consume(ch, i + offset);
|
|
}
|
|
}
|
|
|
|
for (let bufs of this.consumed_) {
|
|
this.port.postMessage(bufs, bufs.map(buf => buf.buffer));
|
|
}
|
|
this.consumed_ = [];
|
|
|
|
return true;
|
|
}
|
|
}
|
|
|
|
registerProcessor('ebiten-audio-worklet-processor', EbitenAudioWorkletProcessor);`
|
|
scriptURL := "data:application/javascript;base64," + base64.StdEncoding.EncodeToString([]byte(script))
|
|
|
|
ch := make(chan error)
|
|
worklet.Call("addModule", scriptURL).Call("then", js.FuncOf(func(js.Value, []js.Value) interface{} {
|
|
close(ch)
|
|
return nil
|
|
})).Call("catch", js.FuncOf(func(this js.Value, args []js.Value) interface{} {
|
|
err := args[0]
|
|
ch <- fmt.Errorf("oto: error at addModule: %s: %s", err.Get("name").String(), err.Get("message").String())
|
|
close(ch)
|
|
return nil
|
|
}))
|
|
if err := <-ch; err != nil {
|
|
return js.Undefined(), err
|
|
}
|
|
|
|
options := js.Global().Get("Object").New()
|
|
arr := js.Global().Get("Array").New()
|
|
arr.Call("push", channelNum)
|
|
options.Set("outputChannelCount", arr)
|
|
|
|
node := js.Global().Get("AudioWorkletNode").New(context, "ebiten-audio-worklet-processor", options)
|
|
node.Call("connect", context.Get("destination"))
|
|
|
|
return node, nil
|
|
}
|
|
|
|
func newDriver(sampleRate, channelNum, bitDepthInBytes, bufferSize int) (tryWriteCloser, error) {
|
|
if js.Global().Get("go2cpp").Truthy() {
|
|
return newDriverGo2Cpp(sampleRate, channelNum, bitDepthInBytes, bufferSize)
|
|
}
|
|
|
|
class := js.Global().Get("AudioContext")
|
|
if !class.Truthy() {
|
|
class = js.Global().Get("webkitAudioContext")
|
|
}
|
|
if !class.Truthy() {
|
|
return nil, errors.New("oto: audio couldn't be initialized")
|
|
}
|
|
|
|
options := js.Global().Get("Object").New()
|
|
options.Set("sampleRate", sampleRate)
|
|
context := class.New(options)
|
|
|
|
node, err := tryAudioWorklet(context, channelNum)
|
|
if err != nil {
|
|
w, ok := err.(*warn)
|
|
if !ok {
|
|
return nil, err
|
|
}
|
|
js.Global().Get("console").Call("warn", w.Error())
|
|
}
|
|
|
|
bs := bufferSize
|
|
if !node.Truthy() {
|
|
bs = max(bufferSize, audioBufferSamples*channelNum*bitDepthInBytes)
|
|
} else {
|
|
bs = max(bufferSize, 4096)
|
|
}
|
|
|
|
p := &driver{
|
|
sampleRate: sampleRate,
|
|
channelNum: channelNum,
|
|
bitDepthInBytes: bitDepthInBytes,
|
|
context: context,
|
|
workletNode: node,
|
|
bufferSize: bs,
|
|
}
|
|
|
|
if node.Truthy() {
|
|
port := node.Get("port")
|
|
p.workletNodePost = port.Get("postMessage").Call("bind", port)
|
|
p.messageArray = js.Global().Get("Array").New(2)
|
|
p.transferArray = js.Global().Get("Array").New(2)
|
|
p.cond = sync.NewCond(&sync.Mutex{})
|
|
|
|
s := p.bufferSize / p.channelNum / p.bitDepthInBytes * 4
|
|
p.bufs = [][]js.Value{
|
|
{
|
|
js.Global().Get("Uint8Array").New(s),
|
|
js.Global().Get("Uint8Array").New(s),
|
|
},
|
|
{
|
|
js.Global().Get("Uint8Array").New(s),
|
|
js.Global().Get("Uint8Array").New(s),
|
|
},
|
|
}
|
|
|
|
node.Get("port").Set("onmessage", js.FuncOf(func(this js.Value, args []js.Value) interface{} {
|
|
p.cond.L.Lock()
|
|
defer p.cond.L.Unlock()
|
|
|
|
bufs := args[0].Get("data")
|
|
var arr []js.Value
|
|
for i := 0; i < bufs.Length(); i++ {
|
|
arr = append(arr, bufs.Index(i))
|
|
}
|
|
|
|
notify := len(p.bufs) == 0
|
|
p.bufs = append(p.bufs, arr)
|
|
if notify {
|
|
p.cond.Signal()
|
|
}
|
|
|
|
return nil
|
|
}))
|
|
}
|
|
|
|
setCallback := func(event string) js.Func {
|
|
var f js.Func
|
|
f = js.FuncOf(func(this js.Value, arguments []js.Value) interface{} {
|
|
if !p.ready {
|
|
p.context.Call("resume")
|
|
p.ready = true
|
|
}
|
|
js.Global().Get("document").Call("removeEventListener", event, f)
|
|
return nil
|
|
})
|
|
js.Global().Get("document").Call("addEventListener", event, f)
|
|
p.callbacks[event] = f
|
|
return f
|
|
}
|
|
|
|
// Browsers require user interaction to start the audio.
|
|
// https://developers.google.com/web/updates/2017/09/autoplay-policy-changes#webaudio
|
|
p.callbacks = map[string]js.Func{}
|
|
setCallback("touchend")
|
|
setCallback("keyup")
|
|
setCallback("mouseup")
|
|
return p, nil
|
|
}
|
|
|
|
func toLR(data []byte) ([]float32, []float32) {
|
|
const max = 1 << 15
|
|
|
|
l := make([]float32, len(data)/4)
|
|
r := make([]float32, len(data)/4)
|
|
for i := 0; i < len(data)/4; i++ {
|
|
l[i] = float32(int16(data[4*i])|int16(data[4*i+1])<<8) / max
|
|
r[i] = float32(int16(data[4*i+2])|int16(data[4*i+3])<<8) / max
|
|
}
|
|
return l, r
|
|
}
|
|
|
|
func (p *driver) TryWrite(data []byte) (int, error) {
|
|
if !p.ready {
|
|
return 0, nil
|
|
}
|
|
|
|
if p.workletNode.Truthy() {
|
|
p.cond.L.Lock()
|
|
defer p.cond.L.Unlock()
|
|
|
|
n := min(len(data), max(0, p.bufferSize-len(p.tmp)))
|
|
p.tmp = append(p.tmp, data[:n]...)
|
|
|
|
if len(p.tmp) < p.bufferSize {
|
|
return n, nil
|
|
}
|
|
|
|
for len(p.bufs) == 0 {
|
|
p.cond.Wait()
|
|
}
|
|
|
|
l, r := toLR(p.tmp[:p.bufferSize])
|
|
tl := p.bufs[0][0]
|
|
tr := p.bufs[0][1]
|
|
copyFloat32sToJS(tl, l)
|
|
copyFloat32sToJS(tr, r)
|
|
p.tmp = p.tmp[p.bufferSize:]
|
|
|
|
bufs := p.messageArray
|
|
bufs.SetIndex(0, tl)
|
|
bufs.SetIndex(1, tr)
|
|
transfers := p.transferArray
|
|
transfers.SetIndex(0, tl.Get("buffer"))
|
|
transfers.SetIndex(1, tr.Get("buffer"))
|
|
|
|
p.workletNodePost.Invoke(bufs, transfers)
|
|
|
|
p.bufs = p.bufs[1:]
|
|
|
|
return n, nil
|
|
}
|
|
|
|
n := min(len(data), max(0, p.bufferSize-len(p.tmp)))
|
|
p.tmp = append(p.tmp, data[:n]...)
|
|
|
|
c := p.context.Get("currentTime").Float()
|
|
|
|
if p.nextPos < c {
|
|
p.nextPos = c
|
|
}
|
|
|
|
// It's too early to enqueue a buffer.
|
|
// Highly likely, there are two playing buffers now.
|
|
if c+float64(p.bufferSize/p.bitDepthInBytes/p.channelNum)/float64(p.sampleRate) < p.nextPos {
|
|
return n, nil
|
|
}
|
|
|
|
le := audioBufferSamples * p.bitDepthInBytes * p.channelNum
|
|
if len(p.tmp) < le {
|
|
return n, nil
|
|
}
|
|
|
|
buf := p.context.Call("createBuffer", p.channelNum, audioBufferSamples, p.sampleRate)
|
|
l, r := toLR(p.tmp[:le])
|
|
tl, freel := float32SliceToTypedArray(l)
|
|
tr, freer := float32SliceToTypedArray(r)
|
|
if buf.Get("copyToChannel").Truthy() {
|
|
buf.Call("copyToChannel", tl, 0, 0)
|
|
buf.Call("copyToChannel", tr, 1, 0)
|
|
} else {
|
|
// copyToChannel is not defined on Safari 11
|
|
buf.Call("getChannelData", 0).Call("set", tl)
|
|
buf.Call("getChannelData", 1).Call("set", tr)
|
|
}
|
|
freel()
|
|
freer()
|
|
|
|
s := p.context.Call("createBufferSource")
|
|
s.Set("buffer", buf)
|
|
s.Call("connect", p.context.Get("destination"))
|
|
s.Call("start", p.nextPos)
|
|
p.nextPos += buf.Get("duration").Float()
|
|
|
|
p.tmp = p.tmp[le:]
|
|
return n, nil
|
|
}
|
|
|
|
func (p *driver) Close() error {
|
|
for event, f := range p.callbacks {
|
|
// https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/removeEventListener
|
|
// "Calling removeEventListener() with arguments that do not identify any currently registered EventListener on the EventTarget has no effect."
|
|
js.Global().Get("document").Call("removeEventListener", event, f)
|
|
f.Release()
|
|
}
|
|
p.callbacks = nil
|
|
return nil
|
|
}
|
|
|
|
func (d *driver) tryWriteCanReturnWithoutWaiting() bool {
|
|
return true
|
|
}
|