From 21dd6a3f75dc10a99967ef496594e1cbb7e206e8 Mon Sep 17 00:00:00 2001 From: devgianlu Date: Fri, 13 Oct 2023 11:28:03 +0200 Subject: [PATCH] Initial Windows output driver implementation --- go.mod | 4 +- go.sum | 9 +- output/driver_windows.go | 430 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 438 insertions(+), 5 deletions(-) create mode 100644 output/driver_windows.go diff --git a/go.mod b/go.mod index e127a84..f86f7fd 100644 --- a/go.mod +++ b/go.mod @@ -6,10 +6,12 @@ require ( github.com/cenkalti/backoff/v4 v4.2.1 github.com/devgianlu/shannon v0.0.0-20230613115856-82ec90b7fa7e github.com/grandcat/zeroconf v1.0.0 + github.com/moutend/go-wca v0.3.0 github.com/sirupsen/logrus v1.9.3 github.com/xlab/vorbis-go v0.0.0-20210911202351-b5b85f1ec645 golang.org/x/crypto v0.10.0 golang.org/x/exp v0.0.0-20230522175609-2e198f4a06a1 + golang.org/x/sys v0.10.0 google.golang.org/protobuf v1.30.0 gopkg.in/yaml.v3 v3.0.1 nhooyr.io/websocket v1.8.7 @@ -17,10 +19,10 @@ require ( require ( github.com/cenkalti/backoff v2.2.1+incompatible // indirect + github.com/go-ole/go-ole v1.2.6 // indirect github.com/klauspost/compress v1.10.3 // indirect github.com/miekg/dns v1.1.54 // indirect golang.org/x/mod v0.11.0 // indirect golang.org/x/net v0.11.0 // indirect - golang.org/x/sys v0.10.0 // indirect golang.org/x/tools v0.10.0 // indirect ) diff --git a/go.sum b/go.sum index b8daab6..ef74164 100644 --- a/go.sum +++ b/go.sum @@ -11,6 +11,8 @@ github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= github.com/gin-gonic/gin v1.6.3 h1:ahKqKTFpO5KTPHxWZjEdPScmYaGtLo8Y4DMHoEsnp14= github.com/gin-gonic/gin v1.6.3/go.mod h1:75u5sXoLsGZoRN5Sgbi1eraJ4GU3++wFwWzhwvtwp4M= +github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= +github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/locales v0.13.0 h1:HyWk6mgj5qFqCT5fjGBuRArbVDfE4hi8+e8ceBS/t7Q= github.com/go-playground/locales v0.13.0/go.mod h1:taPMhCMXrRLJO55olJkUXHZBHCxTMfnGwq/HNwmWNS8= @@ -36,10 +38,6 @@ github.com/gorilla/websocket v1.4.1 h1:q7AeDBpnBk8AogcD4DSag/Ukw/KV+YhzLj2bP5HvK github.com/gorilla/websocket v1.4.1/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/grandcat/zeroconf v1.0.0 h1:uHhahLBKqwWBV6WZUDAT71044vwOTL+McW0mBJvo6kE= github.com/grandcat/zeroconf v1.0.0/go.mod h1:lTKmG1zh86XyCoUeIHSA4FJMBwCJiQmGfcP2PdzytEs= -github.com/jfreymuth/oggvorbis v1.0.5 h1:u+Ck+R0eLSRhgq8WTmffYnrVtSztJcYrl588DM4e3kQ= -github.com/jfreymuth/oggvorbis v1.0.5/go.mod h1:1U4pqWmghcoVsCJJ4fRBKv9peUJMBHixthRlBeD6uII= -github.com/jfreymuth/vorbis v1.0.2 h1:m1xH6+ZI4thH927pgKD8JOH4eaGRm18rEE9/0WKjvNE= -github.com/jfreymuth/vorbis v1.0.2/go.mod h1:DoftRo4AznKnShRl1GxiTFCseHr4zR9BN3TWXyuzrqQ= github.com/json-iterator/go v1.1.9 h1:9yzud/Ht36ygwatGx56VwCZtlI/2AD15T1X2sjSuGns= github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= github.com/klauspost/compress v1.10.3 h1:OP96hzwJVBIHYU52pVTI6CczrxPvrGfgqF9N5eTO0Q8= @@ -55,6 +53,8 @@ github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OH github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742 h1:Esafd1046DLDQ0W1YjYsBW+p8U2u7vzgW2SQVmlNazg= github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/moutend/go-wca v0.3.0 h1:IzhsQ44zBzMdT42xlBjiLSVya9cPYOoKx9E+yXVhFo8= +github.com/moutend/go-wca v0.3.0/go.mod h1:7VrPO512jnjFGJ6rr+zOoCfiYjOHRPNfbttJuxAurcw= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= @@ -91,6 +91,7 @@ golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.3.0 h1:ftCYgMx6zT/asHUrPw8BLLscYtGznsLAnjq5RH9P66E= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190924154521-2837fb4f24fe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/output/driver_windows.go b/output/driver_windows.go new file mode 100644 index 0000000..01f4415 --- /dev/null +++ b/output/driver_windows.go @@ -0,0 +1,430 @@ +//go:build windows + +package output + +import "C" +import ( + "errors" + "fmt" + "github.com/go-ole/go-ole" + "github.com/moutend/go-wca/pkg/wca" + log "github.com/sirupsen/logrus" + librespot "go-librespot" + "golang.org/x/sys/windows" + "io" + "runtime" + "sync" + "syscall" + "time" + "unsafe" +) + +func isOleError(err error, code uintptr) bool { + var oleError *ole.OleError + if errors.As(err, &oleError) { + return oleError.Code() == code + } + return false +} + +func isFalseError(err error) bool { + return errors.Is(err, syscall.Errno(windows.S_FALSE)) +} + +type comThread struct { + fn chan func() + stop chan struct{} +} + +func newCOMThread() (*comThread, error) { + funcCh, errCh, stopCh := make(chan func()), make(chan error), make(chan struct{}) + go func() { + runtime.LockOSThread() + defer runtime.UnlockOSThread() + + if err := windows.CoInitializeEx(0, windows.COINIT_MULTITHREADED); err != nil && !isFalseError(err) { + errCh <- err + return + } + + errCh <- nil + + loop: + for { + select { + case <-stopCh: + break loop + case fn := <-funcCh: + fn() + } + } + + close(funcCh) + windows.CoUninitialize() + }() + + if err := <-errCh; err != nil { + return nil, err + } + + return &comThread{funcCh, stopCh}, nil +} + +func (com *comThread) Stop() { + com.stop <- struct{}{} +} + +func (com *comThread) Run(f func() error) error { + ch := make(chan error) + com.fn <- func() { ch <- f() } + return <-ch +} + +type output struct { + channels int + sampleRate int + device string + reader librespot.Float32Reader + done chan error + + cond *sync.Cond + + com *comThread + ev windows.Handle + enumerator *wca.IMMDeviceEnumerator + client *wca.IAudioClient2 + renderClient *wca.IAudioRenderClient + bufferFrames uint32 + currentDevice string + + volume float32 + paused bool + closed bool + eof bool +} + +var ( + errDeviceSwitched = errors.New("oto: device switched") +) + +func newOutput(reader librespot.Float32Reader, sampleRate int, channels int, device string, initiallyPaused bool) (_ *output, err error) { + out := &output{ + reader: reader, + channels: channels, + sampleRate: sampleRate, + device: device, // FIXME + volume: 1, + cond: sync.NewCond(&sync.Mutex{}), + done: make(chan error, 1), + } + + if out.com, err = newCOMThread(); err != nil { + return nil, fmt.Errorf("failed creating COM thread: %w", err) + } + + if out.ev, err = windows.CreateEventEx(nil, nil, 0, windows.EVENT_ALL_ACCESS); err != nil { + return nil, fmt.Errorf("failed creating windows event: %w", err) + } + + if err = out.openAndSetup(); err != nil { + _ = windows.CloseHandle(out.ev) + return nil, err + } + + if initiallyPaused { + _ = out.Pause() + } else { + _ = out.Resume() + } + + go func() { + err := out.loop() + _ = out.Close() + + if err != nil { + if errors.Is(err, errDeviceSwitched) || + isOleError(err, wca.AUDCLNT_E_DEVICE_INVALIDATED) || + isOleError(err, 0x88890026 /* AUDCLNT_E_RESOURCES_INVALIDATED */) { + panic("maybe restart?") // FIXME + return + } + + out.done <- err + } else { + out.done <- nil + } + }() + + return out, nil +} + +type CorrectAudioClientProperties struct { + CbSize uint32 + BIsOffload int32 + AUDIO_STREAM_CATEGORY uint32 + AUDCLNT_STREAMOPTIONS uint32 +} + +func (out *output) openAndSetup() error { + return out.com.Run(func() error { + if err := wca.CoCreateInstance(wca.CLSID_MMDeviceEnumerator, 0, wca.CLSCTX_ALL, wca.IID_IMMDeviceEnumerator, &out.enumerator); err != nil { + return fmt.Errorf("failed creating IMMDeviceEnumerator instance: %w", err) + } + + // TODO: allow selecting device id from out.device property + // TODO: use RegisterEndpointNotificationCallback to determine default audio endpoint change + + var device *wca.IMMDevice + if err := out.enumerator.GetDefaultAudioEndpoint(wca.ERender, wca.EConsole, &device); err != nil { + return fmt.Errorf("failed getting default audio endpoint: %w", err) + } + + defer device.Release() + + if err := device.GetId(&out.currentDevice); err != nil { + return fmt.Errorf("failed getting IMMDevice id: %w", err) + } + + log.Debugf("using IMMDevice with id: %s", out.currentDevice) + + if err := device.Activate(wca.IID_IAudioClient2, wca.CLSCTX_ALL, nil, &out.client); err != nil { + return fmt.Errorf("failed activating IAudioClient2: %w", err) + } + + // FIXME: remove this ugly hack when https://github.com/moutend/go-wca/pull/17 is merged + if err := out.client.SetClientProperties((*wca.AudioClientProperties)(unsafe.Pointer(&CorrectAudioClientProperties{ + CbSize: uint32(unsafe.Sizeof(CorrectAudioClientProperties{})), + BIsOffload: 0, + AUDIO_STREAM_CATEGORY: wca.AudioCategory_BackgroundCapableMedia, + }))); err != nil { + return fmt.Errorf("failed setting IAudioClient2 properties: %w", err) + } + + const bitsPerSample = 32 + nBlockAlign := out.channels * bitsPerSample / 8 + + pwfx := &wca.WAVEFORMATEX{ + WFormatTag: 0x3, /* WAVE_FORMAT_IEEE_FLOAT */ + NChannels: uint16(out.channels), + NSamplesPerSec: uint32(out.sampleRate), + NAvgBytesPerSec: uint32(out.sampleRate * nBlockAlign), + NBlockAlign: uint16(nBlockAlign), + WBitsPerSample: bitsPerSample, + CbSize: 0, + } + + // FIXME: ugly hack because library is broken + init := func(shareMode uint32, streamFlags uint32, hnsBufferDuration wca.REFERENCE_TIME, hnsPeriodicity wca.REFERENCE_TIME, pFormat *wca.WAVEFORMATEX, audioSessionGuid *windows.GUID) error { + var r uintptr + if unsafe.Sizeof(uintptr(0)) == 8 { + // 64bits + r, _, _ = syscall.SyscallN(out.client.VTable().Initialize, uintptr(unsafe.Pointer(out.client)), + uintptr(shareMode), uintptr(streamFlags), uintptr(hnsBufferDuration), + uintptr(hnsPeriodicity), uintptr(unsafe.Pointer(pFormat)), uintptr(unsafe.Pointer(audioSessionGuid))) + } else { + // 32bits + r, _, _ = syscall.SyscallN(out.client.VTable().Initialize, uintptr(unsafe.Pointer(out.client)), + uintptr(shareMode), uintptr(streamFlags), uintptr(hnsBufferDuration), + uintptr(hnsBufferDuration>>32), uintptr(hnsPeriodicity), uintptr(hnsPeriodicity>>32), + uintptr(unsafe.Pointer(pFormat)), uintptr(unsafe.Pointer(audioSessionGuid))) + } + runtime.KeepAlive(pFormat) + runtime.KeepAlive(audioSessionGuid) + if r != 0 { + return ole.NewError(r) + } + + return nil + } + + if err := init( + wca.AUDCLNT_SHAREMODE_SHARED, + wca.AUDCLNT_STREAMFLAGS_EVENTCALLBACK|wca.AUDCLNT_STREAMFLAGS_NOPERSIST|wca.AUDCLNT_STREAMFLAGS_AUTOCONVERTPCM, + wca.REFERENCE_TIME(50*time.Millisecond/100), + 0, pwfx, nil, + ); err != nil { + return fmt.Errorf("failed initializing IAudioClient2: %w", err) + } + + if err := out.client.GetBufferSize(&out.bufferFrames); err != nil { + return fmt.Errorf("failed getting buffer size from IAudioClient2: %w", err) + } + + if err := out.client.GetService(wca.IID_IAudioRenderClient, &out.renderClient); err != nil { + return fmt.Errorf("failed getting IAudioRenderClient service from IAudioClient2: %w", err) + } + + if err := out.client.SetEventHandle(uintptr(out.ev)); err != nil { + return fmt.Errorf("failed setting event handle on IAudioClient2: %w", err) + } + + return nil + }) +} + +func (out *output) loop() error { + runtime.LockOSThread() + defer runtime.UnlockOSThread() + + if err := windows.CoInitializeEx(0, windows.COINIT_MULTITHREADED); err != nil && !isFalseError(err) { + return fmt.Errorf("failed initializing COM thread: %w", err) + } + + defer windows.CoUninitialize() + + floats := make([]float32, int(out.bufferFrames)*out.channels) + + for { + out.cond.L.Lock() + for !(!out.paused || out.closed) { + out.cond.Wait() + } + + if out.closed { + out.cond.L.Unlock() + return nil + } + + if evt, err := windows.WaitForSingleObject(out.ev, windows.INFINITE); err != nil { + out.cond.L.Unlock() + return fmt.Errorf("failed waiting for single object: %w", err) + } else if evt != windows.WAIT_OBJECT_0 { + out.cond.L.Unlock() + return fmt.Errorf("unexpected event while waiting for single object: %v", evt) + } + + var currentPadding uint32 + if err := out.client.GetCurrentPadding(¤tPadding); err != nil { + out.cond.L.Unlock() + return fmt.Errorf("failed getting current padding from IAudioClient2: %w", err) + } + + // we can unlock here since we are going to touch only the reader, + // additionally it usually takes a while for it to finish. + out.cond.L.Unlock() + + frames := out.bufferFrames - currentPadding + if frames <= 0 { + continue + } + + n, err := out.reader.Read(floats[:int(frames)*out.channels]) + if errors.Is(err, io.EOF) { + out.eof = true + + // FIXME: this thing churns the data too fast, buffer is way too big + println("EOF") + + // FIXME: consider waiting for audio to drain before exiting + // see GetDevicePeriod(NULL, &hnsRequestedDuration) and + // https://learn.microsoft.com/en-us/windows/win32/coreaudio/exclusive-mode-streams or + // https://learn.microsoft.com/en-us/windows/win32/coreaudio/rendering-a-stream + return nil + } else if err != nil { + return fmt.Errorf("failed reading source: %w", err) + } + + if n%out.channels != 0 { + return fmt.Errorf("invalid read amount: %d", n) + } + + for i := 0; i < n; i++ { + floats[i] *= out.volume + } + + // lock again because we need to touch the render client + out.cond.L.Lock() + if out.closed { + return nil + } + + var buf *byte + if err := out.renderClient.GetBuffer(frames, &buf); err != nil { + out.cond.L.Unlock() + return fmt.Errorf("failed getting buffer from IAudioRenderClient: %w", err) + } + + // we are forced to use copy here and cannot read directly into this buffer, from the docs: + // Clients should avoid excessive delays between the GetBuffer call that + // acquires a buffer and the ReleaseBuffer call that releases the buffer. + copy(unsafe.Slice((*float32)(unsafe.Pointer(buf)), int(frames)*out.channels), floats[:n]) + + if err := out.renderClient.ReleaseBuffer(uint32(n/out.channels), 0); err != nil { + out.cond.L.Unlock() + return fmt.Errorf("failed releasing buffer to IAudioRenderClient: %w", err) + } + + out.cond.L.Unlock() + } +} + +func (out *output) Pause() error { + out.cond.L.Lock() + defer out.cond.L.Unlock() + + if err := out.client.Stop(); err != nil && !isFalseError(err) { + return err + } + + out.paused = true + return nil +} + +func (out *output) Resume() error { + out.cond.L.Lock() + defer out.cond.L.Unlock() + + if err := out.client.Start(); err != nil && !isOleError(err, wca.AUDCLNT_E_NOT_STOPPED) { + return err + } + + out.paused = false + out.cond.Signal() + return nil +} + +func (out *output) SetVolume(vol float32) { + if vol < 0 || vol > 1 { + panic(fmt.Sprintf("invalid volume value: %0.2f", vol)) + } + + out.volume = vol +} + +func (out *output) WaitDone() <-chan error { + out.cond.L.Lock() + defer out.cond.L.Unlock() + + if out.closed { + return nil + } + + return out.done +} + +func (out *output) IsEOF() bool { + out.cond.L.Lock() + defer out.cond.L.Unlock() + + return out.eof +} + +func (out *output) Close() error { + out.cond.L.Lock() + defer out.cond.L.Unlock() + + if out.closed { + return nil + } + + out.com.Stop() + out.renderClient.Release() + out.client.Release() + out.enumerator.Release() + _ = windows.CloseHandle(out.ev) + + out.closed = true + out.cond.Signal() + + return nil +}