471 lines
11 KiB
Go
471 lines
11 KiB
Go
|
// Copyright 2022 The Oto Authors
|
||
|
//
|
||
|
// 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.
|
||
|
|
||
|
package oto
|
||
|
|
||
|
import (
|
||
|
"errors"
|
||
|
"fmt"
|
||
|
"runtime"
|
||
|
"sync"
|
||
|
"syscall"
|
||
|
"time"
|
||
|
"unsafe"
|
||
|
|
||
|
"golang.org/x/sys/windows"
|
||
|
|
||
|
"github.com/ebitengine/oto/v3/internal/mux"
|
||
|
)
|
||
|
|
||
|
type comThread struct {
|
||
|
funcCh chan func()
|
||
|
}
|
||
|
|
||
|
func newCOMThread() (*comThread, error) {
|
||
|
funcCh := make(chan func())
|
||
|
errCh := make(chan error)
|
||
|
go func() {
|
||
|
runtime.LockOSThread()
|
||
|
defer runtime.UnlockOSThread()
|
||
|
|
||
|
// S_FALSE is returned when CoInitializeEx is nested. This is a successful case.
|
||
|
if err := windows.CoInitializeEx(0, windows.COINIT_MULTITHREADED); err != nil && !errors.Is(err, syscall.Errno(windows.S_FALSE)) {
|
||
|
errCh <- err
|
||
|
}
|
||
|
// CoUninitialize should be called even when CoInitializeEx returns S_FALSE.
|
||
|
defer windows.CoUninitialize()
|
||
|
|
||
|
close(errCh)
|
||
|
|
||
|
for f := range funcCh {
|
||
|
f()
|
||
|
}
|
||
|
}()
|
||
|
|
||
|
if err := <-errCh; err != nil {
|
||
|
return nil, err
|
||
|
}
|
||
|
|
||
|
return &comThread{
|
||
|
funcCh: funcCh,
|
||
|
}, nil
|
||
|
}
|
||
|
|
||
|
func (c *comThread) Run(f func()) {
|
||
|
ch := make(chan struct{})
|
||
|
c.funcCh <- func() {
|
||
|
f()
|
||
|
close(ch)
|
||
|
}
|
||
|
<-ch
|
||
|
}
|
||
|
|
||
|
type wasapiContext struct {
|
||
|
sampleRate int
|
||
|
channelCount int
|
||
|
mux *mux.Mux
|
||
|
bufferSizeInBytes int
|
||
|
|
||
|
comThread *comThread
|
||
|
err atomicError
|
||
|
suspended bool
|
||
|
suspendedCond *sync.Cond
|
||
|
|
||
|
sampleReadyEvent windows.Handle
|
||
|
client *_IAudioClient2
|
||
|
bufferFrames uint32
|
||
|
renderClient *_IAudioRenderClient
|
||
|
currentDeviceID string
|
||
|
enumerator *_IMMDeviceEnumerator
|
||
|
|
||
|
buf []float32
|
||
|
|
||
|
m sync.Mutex
|
||
|
}
|
||
|
|
||
|
var (
|
||
|
errDeviceSwitched = errors.New("oto: device switched")
|
||
|
errFormatNotSupported = errors.New("oto: the specified format is not supported (there is the closest format instead)")
|
||
|
)
|
||
|
|
||
|
func newWASAPIContext(sampleRate, channelCount int, mux *mux.Mux, bufferSizeInBytes int) (context *wasapiContext, ferr error) {
|
||
|
t, err := newCOMThread()
|
||
|
if err != nil {
|
||
|
return nil, err
|
||
|
}
|
||
|
|
||
|
c := &wasapiContext{
|
||
|
sampleRate: sampleRate,
|
||
|
channelCount: channelCount,
|
||
|
mux: mux,
|
||
|
bufferSizeInBytes: bufferSizeInBytes,
|
||
|
comThread: t,
|
||
|
suspendedCond: sync.NewCond(&sync.Mutex{}),
|
||
|
}
|
||
|
|
||
|
ev, err := windows.CreateEventEx(nil, nil, 0, windows.EVENT_ALL_ACCESS)
|
||
|
if err != nil {
|
||
|
return nil, err
|
||
|
}
|
||
|
defer func() {
|
||
|
if ferr != nil {
|
||
|
windows.CloseHandle(ev)
|
||
|
}
|
||
|
}()
|
||
|
c.sampleReadyEvent = ev
|
||
|
|
||
|
if err := c.start(); err != nil {
|
||
|
return nil, err
|
||
|
}
|
||
|
|
||
|
return c, nil
|
||
|
}
|
||
|
|
||
|
func (c *wasapiContext) isDeviceSwitched() (bool, error) {
|
||
|
// If the audio is suspended, do nothing.
|
||
|
if c.isSuspended() {
|
||
|
return false, nil
|
||
|
}
|
||
|
|
||
|
var switched bool
|
||
|
var cerr error
|
||
|
c.comThread.Run(func() {
|
||
|
device, err := c.enumerator.GetDefaultAudioEndPoint(eRender, eConsole)
|
||
|
if err != nil {
|
||
|
cerr = err
|
||
|
return
|
||
|
}
|
||
|
defer device.Release()
|
||
|
|
||
|
id, err := device.GetId()
|
||
|
if err != nil {
|
||
|
cerr = err
|
||
|
return
|
||
|
}
|
||
|
|
||
|
if c.currentDeviceID == id {
|
||
|
return
|
||
|
}
|
||
|
switched = true
|
||
|
})
|
||
|
|
||
|
return switched, cerr
|
||
|
}
|
||
|
|
||
|
func (c *wasapiContext) start() error {
|
||
|
var cerr error
|
||
|
c.comThread.Run(func() {
|
||
|
if err := c.startOnCOMThread(); err != nil {
|
||
|
cerr = err
|
||
|
return
|
||
|
}
|
||
|
})
|
||
|
if cerr != nil {
|
||
|
return cerr
|
||
|
}
|
||
|
|
||
|
go func() {
|
||
|
if err := c.loop(); err != nil {
|
||
|
if !errors.Is(err, _AUDCLNT_E_DEVICE_INVALIDATED) && !errors.Is(err, _AUDCLNT_E_RESOURCES_INVALIDATED) && !errors.Is(err, errDeviceSwitched) {
|
||
|
c.err.TryStore(err)
|
||
|
return
|
||
|
}
|
||
|
|
||
|
if err := c.restart(); err != nil {
|
||
|
c.err.TryStore(err)
|
||
|
return
|
||
|
}
|
||
|
}
|
||
|
}()
|
||
|
|
||
|
return nil
|
||
|
}
|
||
|
|
||
|
func (c *wasapiContext) startOnCOMThread() (ferr error) {
|
||
|
if c.enumerator == nil {
|
||
|
e, err := _CoCreateInstance(&uuidMMDeviceEnumerator, nil, uint32(_CLSCTX_ALL), &uuidIMMDeviceEnumerator)
|
||
|
if err != nil {
|
||
|
return err
|
||
|
}
|
||
|
c.enumerator = (*_IMMDeviceEnumerator)(e)
|
||
|
defer func() {
|
||
|
if ferr != nil {
|
||
|
c.enumerator.Release()
|
||
|
c.enumerator = nil
|
||
|
}
|
||
|
}()
|
||
|
}
|
||
|
|
||
|
device, err := c.enumerator.GetDefaultAudioEndPoint(eRender, eConsole)
|
||
|
if err != nil {
|
||
|
if errors.Is(err, _E_NOTFOUND) {
|
||
|
return errDeviceNotFound
|
||
|
}
|
||
|
return err
|
||
|
}
|
||
|
defer device.Release()
|
||
|
|
||
|
id, err := device.GetId()
|
||
|
if err != nil {
|
||
|
return err
|
||
|
}
|
||
|
c.currentDeviceID = id
|
||
|
|
||
|
if c.client != nil {
|
||
|
c.client.Release()
|
||
|
c.client = nil
|
||
|
}
|
||
|
|
||
|
client, err := device.Activate(&uuidIAudioClient2, uint32(_CLSCTX_ALL), nil)
|
||
|
if err != nil {
|
||
|
return err
|
||
|
}
|
||
|
c.client = (*_IAudioClient2)(client)
|
||
|
|
||
|
if err := c.client.SetClientProperties(&_AudioClientProperties{
|
||
|
cbSize: uint32(unsafe.Sizeof(_AudioClientProperties{})),
|
||
|
bIsOffload: 0, // false
|
||
|
eCategory: _AudioCategory_Other, // In the example, AudioCategory_ForegroundOnlyMedia was used, but this value is deprecated.
|
||
|
}); err != nil {
|
||
|
return err
|
||
|
}
|
||
|
|
||
|
// Check the format is supported by WASAPI.
|
||
|
// Stereo with 48000 [Hz] is likely supported, but mono and/or other sample rates are unlikely supported.
|
||
|
// Fallback to WinMM in this case anyway.
|
||
|
const bitsPerSample = 32
|
||
|
nBlockAlign := c.channelCount * bitsPerSample / 8
|
||
|
var channelMask uint32
|
||
|
switch c.channelCount {
|
||
|
case 1:
|
||
|
channelMask = _SPEAKER_FRONT_CENTER
|
||
|
case 2:
|
||
|
channelMask = _SPEAKER_FRONT_LEFT | _SPEAKER_FRONT_RIGHT
|
||
|
}
|
||
|
f := &_WAVEFORMATEXTENSIBLE{
|
||
|
wFormatTag: _WAVE_FORMAT_EXTENSIBLE,
|
||
|
nChannels: uint16(c.channelCount),
|
||
|
nSamplesPerSec: uint32(c.sampleRate),
|
||
|
nAvgBytesPerSec: uint32(c.sampleRate * nBlockAlign),
|
||
|
nBlockAlign: uint16(nBlockAlign),
|
||
|
wBitsPerSample: bitsPerSample,
|
||
|
cbSize: 0x16,
|
||
|
Samples: bitsPerSample,
|
||
|
dwChannelMask: channelMask,
|
||
|
SubFormat: _KSDATAFORMAT_SUBTYPE_IEEE_FLOAT,
|
||
|
}
|
||
|
|
||
|
var bufferSizeIn100ns _REFERENCE_TIME
|
||
|
if c.bufferSizeInBytes != 0 {
|
||
|
bufferSizeInFrames := int64(c.bufferSizeInBytes) / int64(nBlockAlign)
|
||
|
bufferSizeIn100ns = _REFERENCE_TIME(1e7 * bufferSizeInFrames / int64(c.sampleRate))
|
||
|
} else {
|
||
|
// The default buffer size can be too small and might cause glitch noises.
|
||
|
// Specify 50[ms] as the buffer size.
|
||
|
bufferSizeIn100ns = _REFERENCE_TIME(50 * time.Millisecond / 100)
|
||
|
}
|
||
|
|
||
|
// Even if the sample rate and/or the number of channels are not supported by the audio driver,
|
||
|
// AUDCLNT_STREAMFLAGS_AUTOCONVERTPCM should convert the sample rate automatically (#215).
|
||
|
if err := c.client.Initialize(_AUDCLNT_SHAREMODE_SHARED,
|
||
|
_AUDCLNT_STREAMFLAGS_EVENTCALLBACK|_AUDCLNT_STREAMFLAGS_NOPERSIST|_AUDCLNT_STREAMFLAGS_AUTOCONVERTPCM,
|
||
|
bufferSizeIn100ns, 0, f, nil); err != nil {
|
||
|
return err
|
||
|
}
|
||
|
|
||
|
frames, err := c.client.GetBufferSize()
|
||
|
if err != nil {
|
||
|
return err
|
||
|
}
|
||
|
c.bufferFrames = frames
|
||
|
|
||
|
if c.renderClient != nil {
|
||
|
c.renderClient.Release()
|
||
|
c.renderClient = nil
|
||
|
}
|
||
|
|
||
|
renderClient, err := c.client.GetService(&uuidIAudioRenderClient)
|
||
|
if err != nil {
|
||
|
return err
|
||
|
}
|
||
|
c.renderClient = (*_IAudioRenderClient)(renderClient)
|
||
|
|
||
|
if err := c.client.SetEventHandle(c.sampleReadyEvent); err != nil {
|
||
|
return err
|
||
|
}
|
||
|
|
||
|
// TODO: Should some errors be allowed? See WASAPIManager.cpp in the official example SimpleWASAPIPlaySound.
|
||
|
|
||
|
if err := c.client.Start(); err != nil {
|
||
|
return err
|
||
|
}
|
||
|
|
||
|
return nil
|
||
|
}
|
||
|
|
||
|
func (c *wasapiContext) loop() error {
|
||
|
runtime.LockOSThread()
|
||
|
defer runtime.UnlockOSThread()
|
||
|
|
||
|
// S_FALSE is returned when CoInitializeEx is nested. This is a successful case.
|
||
|
if err := windows.CoInitializeEx(0, windows.COINIT_MULTITHREADED); err != nil && !errors.Is(err, syscall.Errno(windows.S_FALSE)) {
|
||
|
_, _ = c.client.Stop()
|
||
|
return err
|
||
|
}
|
||
|
// CoUninitialize should be called even when CoInitializeEx returns S_FALSE.
|
||
|
defer windows.CoUninitialize()
|
||
|
|
||
|
if err := c.loopOnRenderThread(); err != nil {
|
||
|
_, _ = c.client.Stop()
|
||
|
return err
|
||
|
}
|
||
|
|
||
|
return nil
|
||
|
}
|
||
|
|
||
|
func (c *wasapiContext) loopOnRenderThread() error {
|
||
|
last := time.Now()
|
||
|
for {
|
||
|
c.suspendedCond.L.Lock()
|
||
|
for c.suspended {
|
||
|
c.suspendedCond.Wait()
|
||
|
}
|
||
|
c.suspendedCond.L.Unlock()
|
||
|
|
||
|
evt, err := windows.WaitForSingleObject(c.sampleReadyEvent, windows.INFINITE)
|
||
|
if err != nil {
|
||
|
return err
|
||
|
}
|
||
|
if evt != windows.WAIT_OBJECT_0 {
|
||
|
return fmt.Errorf("oto: WaitForSingleObject failed: returned value: %d", evt)
|
||
|
}
|
||
|
|
||
|
if err := c.writeOnRenderThread(); err != nil {
|
||
|
return err
|
||
|
}
|
||
|
|
||
|
// Checking the current default audio device might be an expensive operation.
|
||
|
// Check this repeatedly but with some time interval.
|
||
|
if now := time.Now(); now.Sub(last) >= 500*time.Millisecond {
|
||
|
switched, err := c.isDeviceSwitched()
|
||
|
if err != nil {
|
||
|
return err
|
||
|
}
|
||
|
if switched {
|
||
|
return errDeviceSwitched
|
||
|
}
|
||
|
last = now
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
func (c *wasapiContext) writeOnRenderThread() error {
|
||
|
c.m.Lock()
|
||
|
defer c.m.Unlock()
|
||
|
|
||
|
paddingFrames, err := c.client.GetCurrentPadding()
|
||
|
if err != nil {
|
||
|
return err
|
||
|
}
|
||
|
|
||
|
frames := c.bufferFrames - paddingFrames
|
||
|
if frames <= 0 {
|
||
|
return nil
|
||
|
}
|
||
|
|
||
|
// Get the destination buffer.
|
||
|
dstBuf, err := c.renderClient.GetBuffer(frames)
|
||
|
if err != nil {
|
||
|
return err
|
||
|
}
|
||
|
|
||
|
// Calculate the buffer size.
|
||
|
if buflen := int(frames) * c.channelCount; cap(c.buf) < buflen {
|
||
|
c.buf = make([]float32, buflen)
|
||
|
} else {
|
||
|
c.buf = c.buf[:buflen]
|
||
|
}
|
||
|
|
||
|
// Read the buffer from the players.
|
||
|
c.mux.ReadFloat32s(c.buf)
|
||
|
|
||
|
// Copy the read buf to the destination buffer.
|
||
|
copy(unsafe.Slice((*float32)(unsafe.Pointer(dstBuf)), len(c.buf)), c.buf)
|
||
|
|
||
|
// Release the buffer.
|
||
|
if err := c.renderClient.ReleaseBuffer(frames, 0); err != nil {
|
||
|
return err
|
||
|
}
|
||
|
|
||
|
c.buf = c.buf[:0]
|
||
|
return nil
|
||
|
}
|
||
|
|
||
|
func (c *wasapiContext) Suspend() error {
|
||
|
c.suspendedCond.L.Lock()
|
||
|
c.suspended = true
|
||
|
c.suspendedCond.L.Unlock()
|
||
|
c.suspendedCond.Signal()
|
||
|
|
||
|
return nil
|
||
|
}
|
||
|
|
||
|
func (c *wasapiContext) Resume() error {
|
||
|
c.suspendedCond.L.Lock()
|
||
|
c.suspended = false
|
||
|
c.suspendedCond.L.Unlock()
|
||
|
c.suspendedCond.Signal()
|
||
|
|
||
|
return nil
|
||
|
}
|
||
|
|
||
|
func (c *wasapiContext) isSuspended() bool {
|
||
|
c.suspendedCond.L.Lock()
|
||
|
defer c.suspendedCond.L.Unlock()
|
||
|
return c.suspended
|
||
|
}
|
||
|
|
||
|
func (c *wasapiContext) Err() error {
|
||
|
return c.err.Load()
|
||
|
}
|
||
|
|
||
|
func (c *wasapiContext) restart() error {
|
||
|
// Probably the driver is missing temporarily e.g. plugging out the headset.
|
||
|
// Recreate the device.
|
||
|
|
||
|
retry:
|
||
|
c.suspendedCond.L.Lock()
|
||
|
for c.suspended {
|
||
|
c.suspendedCond.Wait()
|
||
|
}
|
||
|
c.suspendedCond.L.Unlock()
|
||
|
|
||
|
if err := c.start(); err != nil {
|
||
|
// When a device is switched, the new device might not support the desired format,
|
||
|
// or all the audio devices might be disconnected.
|
||
|
// Instead of aborting this context, let's wait for the next device switch.
|
||
|
if !errors.Is(err, errFormatNotSupported) && !errors.Is(err, errDeviceNotFound) {
|
||
|
return err
|
||
|
}
|
||
|
|
||
|
// Just read the buffer and discard it. Then, retry to search the device.
|
||
|
var buf32 [4096]float32
|
||
|
sleep := time.Duration(float64(time.Second) * float64(len(buf32)) / float64(c.channelCount) / float64(c.sampleRate))
|
||
|
c.mux.ReadFloat32s(buf32[:])
|
||
|
time.Sleep(sleep)
|
||
|
goto retry
|
||
|
}
|
||
|
return nil
|
||
|
}
|