Files
aego/platform/web/audio.go
sanspie 5eb1832907
ci-platforms / darwin amd64 smoke (push) Skipped
ci / build (amd64, linux, ubuntu-24.04) (push) Failing after 15m28s
ci / release (push) Skipped
ci-platforms / windows amd64 smoke (push) Skipped
ci-platforms / darwin arm64 smoke (push) Skipped
ci / checks (push) Successful in 7m47s
Complete Stage 12 for v0.12.0
2026-11-05 11:46:53 +03:00

614 lines
14 KiB
Go

//go:build js
package web
import (
_ "embed"
"encoding/json"
"math"
"strconv"
"syscall/js"
"unsafe"
"aego/core/errs"
"aego/platform"
)
const (
audioProcessor = "aego-out"
audioReport = 2048
muteKey = "aego-muted"
soundDefault = "aegoSoundDefault"
)
var currentAudio *Audio
//go:embed worklet.js
var workletSource string
type AudioStats struct {
Running bool `json:"running"`
Shared bool `json:"shared"`
Muted bool `json:"muted"`
Underruns int `json:"underruns"`
RMS float64 `json:"rms"`
Out float64 `json:"out"`
Consumed int64 `json:"consumed"`
Rate int `json:"rate"`
Error string `json:"error,omitempty"`
}
type Audio struct {
g js.Value
muteKey string
pageCtx bool
chunk js.Value
chunkU8 js.Value
chunkLen int
ctx js.Value
node js.Value
port js.Value
f32 js.Value
u8 js.Value
atomics js.Value
data js.Value
views map[[2]int]js.Value
index js.Value
url js.Value
spec platform.AudioSpec
shared bool
target int
ready bool
state string
unlocked bool
hidden bool
paused bool
closed bool
loading bool
tail int32
count sendCount
refill func() int
underruns int
sum float64
samples float64
failed string
listeners []listener
funcs []js.Func
gain js.Value
muted bool
prime []float32
primeMax int
priming bool
sentAudio bool
}
func (p *Platform) OpenAudio(want platform.AudioSpec) (platform.AudioOutput, error) {
return OpenAudio(want)
}
// sharedContextGlobal is an AudioContext the page created inside a click, so it starts running; the docs Try it button makes one before the runtime boots.
const (
sharedContextGlobal = "aegoAudioContext"
workletLoaded = "aegoWorkletLoaded"
)
func sharedContext(g js.Value) (js.Value, bool) {
c := g.Get(sharedContextGlobal)
if !c.Truthy() || c.Get("state").String() == "closed" {
return js.Value{}, false
}
return c, true
}
func newContext(ctor js.Value, rate int) (ctx js.Value, err error) {
defer func() {
if r := recover(); r != nil {
err = errs.New(errs.PlatformInit, "the AudioContext could not be created")
}
}()
opts := map[string]any{"latencyHint": "interactive"}
if rate > 0 {
opts["sampleRate"] = rate
}
return ctor.New(opts), nil
}
func OpenAudio(want platform.AudioSpec) (*Audio, error) {
want = want.WithDefaults()
g := js.Global()
ctor := g.Get("AudioContext")
if !ctor.Truthy() {
ctor = g.Get("webkitAudioContext")
}
if !ctor.Truthy() {
return nil, errs.New(errs.PlatformInit, "this browser has no Web Audio")
}
ctx, pageCtx := sharedContext(g)
if !pageCtx {
var err error
ctx, err = newContext(ctor, want.SampleRate)
if err != nil {
ctx, err = newContext(ctor, 0)
}
if err != nil {
return nil, err
}
}
if !ctx.Get("audioWorklet").Truthy() {
try(ctx, "close")
return nil, errs.New(errs.PlatformInit, "Web Audio has no AudioWorklet here; it needs a secure context")
}
a := &Audio{
pageCtx: pageCtx,
g: g, ctx: ctx, f32: g.Get("Float32Array"), u8: g.Get("Uint8Array"), atomics: g.Get("Atomics"),
state: ctx.Get("state").String(),
}
a.spec = platform.AudioSpec{SampleRate: ctx.Get("sampleRate").Int(), Channels: 2, Frames: want.Frames}
a.shared = g.Get("crossOriginIsolated").Truthy() && g.Get("SharedArrayBuffer").Truthy() && a.atomics.Truthy()
latency := 0.0
if v := ctx.Get("baseLatency"); v.Type() == js.TypeNumber {
latency = v.Float()
}
if a.shared {
a.target = sharedTargetBlocks(latency, a.spec.SampleRate, a.spec.Frames)
} else {
a.target = messageTargetBlocks(latency, a.spec.SampleRate, a.spec.Frames)
}
a.hook(ctx, "statechange", func(js.Value) {
a.state = ctx.Get("state").String()
a.fillRunning()
})
if doc := g.Get("document"); doc.Truthy() {
for _, name := range []string{"pointerdown", "keydown", "touchend"} {
a.hook(doc, name, a.gesture)
}
a.hook(doc, "visibilitychange", func(js.Value) { a.visibility(doc.Get("hidden").Truthy()) })
a.hidden = doc.Get("hidden").Truthy()
}
a.muteKey = muteKey
if d := g.Get(soundDefault); d.Type() == js.TypeBoolean {
a.muteKey, a.muted = muteKey+"-"+strconv.FormatBool(d.Bool()), !d.Bool()
}
if v, ok := storageGet(g, a.muteKey); ok {
a.muted = v == "1"
}
if activated(g) {
a.gesture(js.Undefined())
}
a.publish()
blob := g.Get("Blob").New([]any{workletSource}, map[string]any{"type": "text/javascript"})
a.url = g.Get("URL").Call("createObjectURL", blob)
if ctx.Get(workletLoaded).Truthy() {
a.attach()
return a, nil
}
a.loading = true
loaded := a.fn(func(js.Value, []js.Value) any {
defer a.loadSettled()
ctx.Set(workletLoaded, true)
a.attach()
return nil
})
bad := a.fn(func(_ js.Value, args []js.Value) any {
defer a.loadSettled()
if !a.closed {
a.failed = "the audio worklet did not load"
if len(args) > 0 {
a.failed += ": " + args[0].Call("toString").String()
}
}
return nil
})
ctx.Get("audioWorklet").Call("addModule", a.url).Call("then", loaded, bad)
return a, nil
}
func (a *Audio) fn(f func(js.Value, []js.Value) any) js.Func {
jf := js.FuncOf(f)
a.funcs = append(a.funcs, jf)
return jf
}
func (a *Audio) hook(target js.Value, name string, fn func(js.Value)) {
f := js.FuncOf(func(_ js.Value, args []js.Value) any {
if len(args) > 0 {
fn(args[0])
} else {
fn(js.Undefined())
}
return nil
})
target.Call("addEventListener", name, f, map[string]any{"passive": true, "capture": true})
a.listeners = append(a.listeners, listener{target: target, name: name, fn: f})
}
func (a *Audio) quiet(v js.Value) {
if v.Truthy() && v.Get("catch").Type() == js.TypeFunction {
// Native JS callback can outlive this Go output (notably Close).
v.Call("catch", a.g.Get("Boolean"))
}
}
func newNode(ctx js.Value, po map[string]any) (node js.Value, err error) {
defer func() {
if r := recover(); r != nil {
err = errs.New(errs.PlatformInit, "the audio worklet node could not be created")
}
}()
return js.Global().Get("AudioWorkletNode").New(ctx, audioProcessor, map[string]any{
"numberOfInputs": 0,
"numberOfOutputs": 1,
"outputChannelCount": []any{2},
"processorOptions": po,
}), nil
}
func (a *Audio) attach() {
if a.closed {
return
}
po := map[string]any{"channels": a.spec.Channels, "report": audioReport, "refillAt": a.refillThreshold()}
if a.shared {
sab := a.g.Get("SharedArrayBuffer")
data := sab.New(ringFrames * a.spec.Channels * 4)
index := sab.New(16)
a.data = a.u8.New(data)
a.views = nil
a.index = a.g.Get("Int32Array").New(index)
po["data"] = data
po["index"] = index
}
node, err := newNode(a.ctx, po)
if err != nil {
a.failed = err.Error()
return
}
a.node = node
a.port = node.Get("port")
a.port.Set("onmessage", a.fn(func(_ js.Value, args []js.Value) any {
if len(args) > 0 {
a.report(args[0].Get("data"))
}
return nil
}))
a.gain = a.ctx.Call("createGain")
a.gain.Get("gain").Set("value", a.level())
node.Call("connect", a.gain)
a.gain.Call("connect", a.ctx.Get("destination"))
a.ready = true
}
func (a *Audio) report(d js.Value) {
if !d.Truthy() {
return
}
a.count.consumed = int64(d.Get("consumed").Float())
if t := d.Get("time"); t.Type() == js.TypeNumber {
a.count.at = t.Float()
}
a.underruns = d.Get("underruns").Int()
a.sum = d.Get("sum").Float()
a.samples = d.Get("count").Float()
a.fillRunning()
}
// suspend() is asynchronous. Continue to feed actual running quanta until
// statechange confirms suspension; service the restored device immediately.
func (a *Audio) fillRunning() {
if a.refill != nil && a.Running() && !a.muted {
a.refill()
}
}
func (a *Audio) gesture(js.Value) {
a.unlocked = true
if !a.hidden && !a.paused && a.state != "running" {
a.quiet(try(a.ctx, "resume"))
}
}
func (a *Audio) visibility(hidden bool) {
a.hidden = hidden
switch {
case hidden:
a.fillRunning()
a.quiet(try(a.ctx, "suspend"))
case a.unlocked && !a.paused:
a.quiet(try(a.ctx, "resume"))
}
}
func (a *Audio) Running() bool {
return !a.closed && a.ready && a.state == "running"
}
func (a *Audio) TargetBlocks() int {
if a.shared {
return a.target
}
if n := a.g.Get("aegoAudioBlocks"); n.Type() == js.TypeNumber && n.Int() > 0 {
return n.Int()
}
return max(messageMinimumBlocks, a.target)
}
func (a *Audio) Spec() platform.AudioSpec {
return a.spec
}
func floatBytes(s []float32) []byte {
return unsafe.Slice((*byte)(unsafe.Pointer(unsafe.SliceData(s))), len(s)*4)
}
// BeginAudioPrime buffers only the first pump fill. The worklet still counts
// every shortage after playback begins; no silence is reclassified as audio.
func (a *Audio) BeginAudioPrime(frames int) {
if a.sentAudio || a.priming || !a.Running() || frames <= 0 || a.spec.Channels <= 0 {
return
}
a.primeMax = min(frames, ringFrames)
a.prime = make([]float32, 0, a.primeMax*a.spec.Channels)
a.priming = true
}
// EndAudioPrime publishes the initial PCM in one message or one shared-ring
// tail update. A later stall is not re-primed or ignored by the worklet.
func (a *Audio) EndAudioPrime() {
if !a.priming {
return
}
a.priming = false
frames := len(a.prime) / a.spec.Channels
if frames > 0 && (!a.Running() || a.writePCM(a.prime) != frames) {
a.failed = "the initial audio buffer could not be committed"
}
a.prime = nil
a.primeMax = 0
}
func (a *Audio) Write(s []float32) int {
ch := a.spec.Channels
frames := len(s) / ch
if frames == 0 || a.closed {
return 0
}
if !a.Running() {
return frames
}
if a.priming {
frames = min(frames, a.primeMax-len(a.prime)/ch)
a.prime = append(a.prime, s[:frames*ch]...)
return frames
}
return a.writePCM(s[:frames*ch])
}
func (a *Audio) writePCM(s []float32) int {
ch := a.spec.Channels
frames := len(s) / ch
if a.shared {
written := a.writeShared(s[:frames*ch])
a.sentAudio = a.sentAudio || written > 0
return written
}
if a.chunkLen != frames*ch {
a.chunkLen = frames * ch
a.chunk = a.f32.New(a.chunkLen)
a.chunkU8 = a.u8.New(a.chunk.Get("buffer"))
}
js.CopyBytesToJS(a.chunkU8, floatBytes(s[:frames*ch]))
a.port.Call("postMessage", a.chunk)
a.count.sent += int64(frames)
a.sentAudio = true
return frames
}
const maxViews = 64
// SetRefill permits the worklet's existing consumption reports to service the
// same mixer between visual frames. No additional browser timer is installed.
func (a *Audio) SetRefill(fill func() int) {
a.refill = fill
if a.port.Truthy() {
a.port.Call("postMessage", map[string]any{"refillAt": a.refillThreshold()})
}
}
func (a *Audio) refillThreshold() int {
if a.refill == nil {
return 0
}
return min(3*a.spec.Frames, (a.spec.SampleRate*24+999)/1000)
}
func (a *Audio) FrameDriven() bool {
return true
}
func (a *Audio) view(from, to int) js.Value {
k := [2]int{from, to}
if v, ok := a.views[k]; ok {
return v
}
if a.views == nil || len(a.views) >= maxViews {
a.views = make(map[[2]int]js.Value, maxViews)
}
v := a.data.Call("subarray", from, to)
a.views[k] = v
return v
}
func (a *Audio) writeShared(s []float32) int {
ch := a.spec.Channels
head := int32(a.atomics.Call("load", a.index, 0).Int())
n := min(len(s)/ch, ringFree(head, a.tail, ringFrames))
if n <= 0 {
return 0
}
pos, first := ringSpan(a.tail, ringFrames, n)
js.CopyBytesToJS(a.view(pos*ch*4, (pos+first)*ch*4), floatBytes(s[:first*ch]))
if n > first {
js.CopyBytesToJS(a.view(0, (n-first)*ch*4), floatBytes(s[first*ch:n*ch]))
}
a.tail += int32(n)
a.atomics.Call("store", a.index, 1, a.tail)
return n
}
func (a *Audio) Queued() int {
if !a.Running() {
return 0
}
if a.shared {
return ringQueued(int32(a.atomics.Call("load", a.index, 0).Int()), a.tail)
}
return a.count.queuedAt(a.ctx.Get("currentTime").Float(), a.spec.SampleRate, 2*audioReport)
}
func (a *Audio) Pause(on bool) {
a.paused = on
switch {
case on:
a.fillRunning()
a.quiet(try(a.ctx, "suspend"))
case a.unlocked && !a.hidden:
a.quiet(try(a.ctx, "resume"))
}
}
func (a *Audio) Stats() AudioStats {
s := AudioStats{Running: a.Running(), Shared: a.shared, Muted: a.muted, Underruns: a.underruns, Consumed: a.count.consumed, Rate: a.spec.SampleRate, Error: a.failed}
if a.samples > 0 {
s.RMS = math.Sqrt(a.sum / a.samples)
}
s.Out = s.RMS * a.level()
return s
}
func (a *Audio) Close() {
if a.closed {
return
}
a.closed = true
a.refill = nil
if a.node.Truthy() {
a.port.Set("onmessage", js.Null())
a.node.Call("disconnect")
}
if a.gain.Truthy() {
a.gain.Call("disconnect")
}
if !a.pageCtx {
a.quiet(try(a.ctx, "close"))
}
for _, l := range a.listeners {
l.target.Call("removeEventListener", l.name, l.fn, map[string]any{"capture": true})
l.fn.Release()
}
a.listeners = nil
if a.url.Truthy() {
a.g.Get("URL").Call("revokeObjectURL", a.url)
}
if currentAudio == a {
a.g.Delete("aegoAudioStats")
a.g.Delete("aegoAudioMute")
currentAudio = nil
}
if !a.loading {
a.releaseFunctions()
}
}
func (a *Audio) loadSettled() {
a.loading = false
// Promise handlers cannot be detached. Release them only after success
// or failure, including if Close happened during addModule.
if a.closed {
a.releaseFunctions()
}
}
func (a *Audio) releaseFunctions() {
for _, fn := range a.funcs {
fn.Release()
}
a.funcs = nil
}
func storageGet(g js.Value, key string) (v string, ok bool) {
defer func() {
if recover() != nil {
v, ok = "", false
}
}()
ls := g.Get("localStorage")
if !ls.Truthy() {
return "", false
}
r := ls.Call("getItem", key)
if r.Type() != js.TypeString {
return "", false
}
return r.String(), true
}
func storageSet(g js.Value, key, v string) {
defer func() { _ = recover() }()
if ls := g.Get("localStorage"); ls.Truthy() {
ls.Call("setItem", key, v)
}
}
func activated(g js.Value) (on bool) {
defer func() {
if recover() != nil {
on = false
}
}()
ua := g.Get("navigator").Get("userActivation")
return ua.Truthy() && ua.Get("hasBeenActive").Truthy()
}
func (a *Audio) level() float64 {
if a.muted {
return 0
}
return 1
}
func (a *Audio) Muted() bool {
return a.muted
}
func (a *Audio) SetMuted(on bool) {
a.muted = on
a.fillRunning()
if a.gain.Truthy() {
a.gain.Get("gain").Call("setTargetAtTime", a.level(), a.ctx.Get("currentTime"), 0.01)
}
v := "0"
if on {
v = "1"
}
storageSet(a.g, a.muteKey, v)
}
func (a *Audio) publish() {
currentAudio = a
a.g.Set("aegoAudioStats", a.fn(func(js.Value, []js.Value) any {
data, _ := json.Marshal(a.Stats())
return string(data)
}))
a.g.Set("aegoAudioMute", a.fn(func(_ js.Value, args []js.Value) any {
if len(args) > 0 && args[0].Type() == js.TypeBoolean {
a.SetMuted(args[0].Bool())
}
return a.muted
}))
}
var (
_ platform.Audioer = (*Platform)(nil)
_ platform.AudioOutput = (*Audio)(nil)
)