diff --git a/.github/actions/virtual-audio-device/LICENSE b/.github/actions/virtual-audio-device/LICENSE new file mode 100644 index 0000000..b788a7c --- /dev/null +++ b/.github/actions/virtual-audio-device/LICENSE @@ -0,0 +1,29 @@ +BSD 3-Clause License + +Copyright (c) 2019, Laboratory for Auditory Brain Science and Neuroengineering +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/.github/actions/virtual-audio-device/README.md b/.github/actions/virtual-audio-device/README.md new file mode 100644 index 0000000..6f850f3 --- /dev/null +++ b/.github/actions/virtual-audio-device/README.md @@ -0,0 +1,3 @@ +# virtual-audio-device + +Adapted from sound-ci-helpers: https://github.com/LABSN/sound-ci-helpers diff --git a/.github/actions/virtual-audio-device/action.yml b/.github/actions/virtual-audio-device/action.yml new file mode 100644 index 0000000..9ffecfd --- /dev/null +++ b/.github/actions/virtual-audio-device/action.yml @@ -0,0 +1,18 @@ +name: 'virtual-audio-device' +description: 'set up virtual audio devices for win32, linux, darwin' +inputs: + platform: + description: 'Target platform' + required: true +runs: + using: 'composite' + steps: + - run: '${{ github.action_path }}/linux/setup_sound.sh' + shell: bash -e {0} + if: ${{ inputs.platform == 'linux' }} + - run: '${{ github.action_path }}/macos/setup_sound.sh' + shell: bash -e {0} + if: ${{ inputs.platform == 'darwin' }} + - run: '${{ github.action_path }}/windows/setup_sound.ps1' + shell: powershell -Command {0} + if: ${{ inputs.platform == 'win32' }} diff --git a/.github/actions/virtual-audio-device/linux/setup_sound.sh b/.github/actions/virtual-audio-device/linux/setup_sound.sh new file mode 100755 index 0000000..e48a8fe --- /dev/null +++ b/.github/actions/virtual-audio-device/linux/setup_sound.sh @@ -0,0 +1,9 @@ +#!/bin/bash + +set -xeo pipefail + +sudo apt update +sudo apt install -y pulseaudio libportaudio2 dbus-x11 libasound-dev +systemctl --user restart pulseaudio.service +systemctl --user restart pulseaudio.socket +pactl list diff --git a/.github/actions/virtual-audio-device/macos/setup_sound.sh b/.github/actions/virtual-audio-device/macos/setup_sound.sh new file mode 100755 index 0000000..1067f95 --- /dev/null +++ b/.github/actions/virtual-audio-device/macos/setup_sound.sh @@ -0,0 +1,6 @@ +#!/bin/bash + +set -xeo pipefail +brew fetch --retry --cask background-music +brew install --cask background-music +sudo launchctl kickstart -kp system/com.apple.audio.coreaudiod || sudo killall coreaudiod diff --git a/.github/actions/virtual-audio-device/windows/devcon.exe b/.github/actions/virtual-audio-device/windows/devcon.exe new file mode 100755 index 0000000..ae9a389 Binary files /dev/null and b/.github/actions/virtual-audio-device/windows/devcon.exe differ diff --git a/.github/actions/virtual-audio-device/windows/setup_sound.ps1 b/.github/actions/virtual-audio-device/windows/setup_sound.ps1 new file mode 100755 index 0000000..8368de7 --- /dev/null +++ b/.github/actions/virtual-audio-device/windows/setup_sound.ps1 @@ -0,0 +1,73 @@ +param ( + [string]$Url = 'https://download.vb-audio.com/Download_CABLE/VBCABLE_Driver_Pack45.zip', + [string]$ZipName = 'vbcable.zip', + [string]$ExtractDir = 'vbcable' +) + +function Download-Zip { + param($Url, $Out) + Write-Host "Downloading $Url → $Out" + $wc = New-Object System.Net.WebClient + 0..2 | ForEach-Object { + try { $wc.DownloadFile($Url, $Out); return } catch { Start-Sleep 1 } + } + $wc.DownloadFile($Url, $Out) +} + +function Expand-Zip { + param($Zip, $Dest) + Write-Host "Extracting $Zip → $Dest" + Expand-Archive -LiteralPath $Zip -DestinationPath $Dest -Force +} + +function Trust-Cert { + param($Cer) + Write-Host "Adding certificate to TrustedPublisher: $Cer" + certutil -addstore -f TrustedPublisher $Cer +} + +function Install-Driver { + param($InfDir) + Write-Host "Installing driver(s) from $InfDir" + Get-ChildItem -Path $InfDir -Filter 'vbMmeCable64_win*.inf' -Recurse | + ForEach-Object { + $inf = $_.FullName + Write-Host " devcon.exe install `"$inf`" VBAudioVACWDM" + & "$PSScriptRoot\devcon.exe" install "$inf" VBAudioVACWDM + } +} + +function Restart-AudioServices { + Write-Host "Restarting Audio services to pick up new device" + foreach ($svc in 'AudioSrv','AudioEndpointBuilder') { + if (Get-Service $svc -ErrorAction SilentlyContinue) { + Restart-Service -Name $svc -Force + } + } +} + +function Dump-Devices { + Write-Host "Current sound devices:" + Get-CimInstance Win32_SoundDevice | + Select-Object Name,Status | + Format-Table -AutoSize +} + +Push-Location $PSScriptRoot + +Download-Zip -Url $Url -Out (Join-Path $PSScriptRoot $ZipName) +Expand-Zip -Zip (Join-Path $PSScriptRoot $ZipName) -Dest (Join-Path $PSScriptRoot $ExtractDir) + +$cer = Join-Path $PSScriptRoot 'vbcable.cer' +if (Test-Path $cer) { + Write-Host "Adding certificate to TrustedPublisher: $cer" + certutil.exe -addstore -f TrustedPublisher $cer +} else { + Write-Error "Certificate not found at $cer; cannot trust the driver." +} +Install-Driver -InfDir (Join-Path $PSScriptRoot $ExtractDir) + +Restart-AudioServices +Dump-Devices + +Pop-Location diff --git a/.github/actions/virtual-audio-device/windows/vbcable.cer b/.github/actions/virtual-audio-device/windows/vbcable.cer new file mode 100644 index 0000000..ca48f14 Binary files /dev/null and b/.github/actions/virtual-audio-device/windows/vbcable.cer differ diff --git a/.github/workflows/prebuild.yml b/.github/workflows/prebuild.yml index 8a47f01..ad00280 100644 --- a/.github/workflows/prebuild.yml +++ b/.github/workflows/prebuild.yml @@ -31,6 +31,19 @@ jobs: - uses: actions/setup-node@v4 with: node-version: lts/* + - uses: ./.github/actions/virtual-audio-device + with: + platform: ${{ matrix.platform }} + arch: ${{ matrix.arch }} + - run: | + choco install vb-cable -y + choco upgrade llvm + if: ${{ matrix.platform == 'win32' }} + - shell: pwsh + if: ${{ matrix.platform == 'win32' }} + run: | + New-Item -Path 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\AppPrivacy\' -Force + New-ItemProperty -Path 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\AppPrivacy\' -Name 'LetAppsAccessMicrophone' -Value 1 -PropertyType DWord - run: | sudo apt update sudo apt install -y \ @@ -41,8 +54,19 @@ jobs: libxi-dev \ libxfixes-dev \ libwayland-dev \ - xvfb + xvfb \ + libpulse-dev \ + pulseaudio if: ${{ matrix.platform == 'linux' }} + - run: pulseaudio -D --start --exit-idle-time=-1 + if: ${{ matrix.platform == 'linux' }} + - run: | + brew install switchaudio-osx blackhole-2ch + sudo pkill coreaudiod || true + sleep 10 + SwitchAudioSource -s 'BlackHole 2ch' -t input + SwitchAudioSource -s 'BlackHole 2ch' -t output + if: ${{ matrix.platform == 'darwin' }} - run: npm install -g bare-runtime bare-make - run: npm install - run: bare-make generate --platform ${{ matrix.platform }} --arch ${{ matrix.arch }} ${{ matrix.flags }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 29ea5b3..b1621d7 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -27,6 +27,18 @@ jobs: - uses: actions/setup-node@v4 with: node-version: lts/* + - uses: ./.github/actions/virtual-audio-device + with: + platform: ${{ matrix.platform }} + - run: | + choco install vb-cable -y + choco upgrade llvm + if: ${{ matrix.platform == 'win32' }} + - shell: pwsh + if: ${{ matrix.platform == 'win32' }} + run: | + New-Item -Path 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\AppPrivacy\' -Force + New-ItemProperty -Path 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\AppPrivacy\' -Name 'LetAppsAccessMicrophone' -Value 1 -PropertyType DWord - run: | sudo apt update sudo apt install -y \ @@ -37,10 +49,19 @@ jobs: libxi-dev \ libxfixes-dev \ libwayland-dev \ - xvfb + xvfb \ + libpulse-dev \ + pulseaudio if: ${{ matrix.platform == 'linux' }} - - run: choco upgrade llvm - if: ${{ matrix.platform == 'win32' }} + - run: pulseaudio -D --start --exit-idle-time=-1 + if: ${{ matrix.platform == 'linux' }} + - run: | + brew install switchaudio-osx blackhole-2ch + sudo pkill coreaudiod || true + sleep 10 + SwitchAudioSource -s 'BlackHole 2ch' -t input + SwitchAudioSource -s 'BlackHole 2ch' -t output + if: ${{ matrix.platform == 'darwin' }} - run: npm install -g bare-runtime bare-make - run: npm install - run: bare-make generate --platform ${{ matrix.platform }} --arch ${{ matrix.arch }} --debug ${{ matrix.flags }} diff --git a/README.md b/README.md index c030560..fdaaa5c 100644 --- a/README.md +++ b/README.md @@ -199,9 +199,200 @@ Parameters: **Returns**: `boolean` indicating if an event was polled +### `AudioDevice` + +The `AudioDevice` API provides functionality to manage SDL audio devices for playback and recording. + +```js +const device = new sdl.AudioDevice(deviceId[, spec]) +``` + +Parameters: + +- `deviceId` (`number`): The audio device ID. Use `constants.SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK` or `constants.SDL_AUDIO_DEVICE_DEFAULT_RECORDING` for defaults. +- `spec` (`object`, optional): Audio specification with the following properties: + - `format` (`number`): Audio format (e.g., `constants.SDL_AUDIO_F32`) + - `channels` (`number`): Number of audio channels (e.g., 2 for stereo) + - `freq` (`number`): Sample rate in Hz (e.g., 48000) + +**Returns**: A new `AudioDevice` instance + +#### Properties + +##### `AudioDevice.name` + +Gets the device name. + +**Returns**: `string` + +##### `AudioDevice.format` + +Gets the audio device format. + +**Returns**: `AudioDeviceFormat` instance + +##### `AudioDevice.isPlaybackDevice` + +Indicates if this is a playback device. + +**Returns**: `boolean` + +##### `AudioDevice.isPhysicalDevice` + +Indicates if this is a physical device. + +**Returns**: `boolean` + +##### `AudioDevice.isPaused` + +Indicates if the device is paused. + +**Returns**: `boolean` + +##### `AudioDevice.gain` + +Gets or sets the device gain (volume). + +**Returns**: `number` (0.0 to 1.0) + +#### Methods + +##### `AudioDevice.pause()` + +Pauses audio playback/recording. + +**Returns**: `boolean` indicating success + +##### `AudioDevice.resume()` + +Resumes audio playback/recording. + +**Returns**: `boolean` indicating success + +##### `AudioDevice.close()` + +Closes the audio device. + +**Returns**: `void` + +#### Static Methods + +##### `AudioDevice.playbackDeviceFormats()` + +Gets the audio formats supported by all playback devices. + +**Returns**: `AudioDeviceFormat[]` - Array of audio device formats + +##### `AudioDevice.recordingDeviceFormats()` + +Gets the audio formats supported by all recording devices. + +**Returns**: `AudioDeviceFormat[]` - Array of audio device formats + +##### `AudioDevice.playbackDevices()` + +Gets all available playback devices. + +**Returns**: `AudioDevice[]` - An array of audio devices + +##### `AudioDevice.recordingDevices()` + +Gets all available recording devices. + +**Returns**: `AudioDevice[]` - An array of audio devices + +##### `AudioDevice.defaultPlaybackDevice([spec])` + +Creates a new instance of `AudioDevice` for the default audio playback device. Equivalent to calling `new AudioDevice(constants.SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK, spec)` + +Parameters: + +- `spec` (`object`, optional): Audio specification (same as `open()`) + +**Returns**: `AudioDevice` - The default playback device + +##### `AudioDevice.defaultRecordingDevice([spec])` + +Creates a new instance of `AudioDevice` for the default audio recording device. Equivalent to calling `new AudioDevice(constants.SDL_AUDIO_DEVICE_DEFAULT_RECORDING, spec)` + +Parameters: + +- `spec` (`object`, optional): Audio specification (same as `open()`) + +**Returns**: `AudioDevice` - The default recording device + +### `AudioDevice.AudioDeviceFormat` + +Represents the format of an audio device. + +```js +const format = new sdl.AudioDevice.AudioDeviceFormat(deviceId) +``` + +Parameters: + +- `deviceId` (`number`): The audio device ID + +**Returns**: A new `AudioDeviceFormat` instance + +#### Properties + +##### `AudioDeviceFormat.valid` + +Indicates if the format is valid. + +**Returns**: `boolean` + +##### `AudioDeviceFormat.sampleFrames` + +Gets the number of sample frames. + +**Returns**: `number` + +##### `AudioDeviceFormat.spec` + +Gets the audio specification. + +**Returns**: `AudioSpec` instance + +### `AudioDevice.AudioSpec` + +Represents an audio specification. + +```js +const spec = new sdl.AudioDevice.AudioSpec(format) +``` + +Parameters: + +- `format` (`AudioDeviceFormat`): The audio device format + +**Returns**: A new `AudioSpec` instance + +#### Properties + +##### `AudioSpec.format` + +Gets the audio format. + +**Returns**: `number` (e.g., `constants.SDL_AUDIO_F32`) + +##### `AudioSpec.channels` + +Gets the number of channels. + +**Returns**: `number` + +##### `AudioSpec.freq` + +Gets the sample rate in Hz. + +**Returns**: `number` + ## Examples -- [Video playback with `bare-ffmpeg`](./example/video-playback.js) +- [Video playback with `bare-ffmpeg`](./examples/video-playback.js) +- [List available audio playback and recording devices](./examples/audio-device-list.js) ## License diff --git a/binding.cc b/binding.cc index 40b37b9..85738a5 100644 --- a/binding.cc +++ b/binding.cc @@ -24,13 +24,24 @@ typedef struct { SDL_KeyboardEvent handle; } bare_sdl_keyboard_event_t; +typedef struct { + SDL_AudioDeviceID *devices; + int count; +} bare_sdl_audio_device_list_t; + +typedef struct { + SDL_AudioSpec spec; + int sample_frames; + bool valid; +} bare_sdl_audio_device_format_t; + static uv_once_t bare_sdl__init_guard = UV_ONCE_INIT; static void bare_sdl__on_init(void) { // Note: This is a way to prevent SDL to handle signals SDL_SetHint(SDL_HINT_NO_SIGNAL_HANDLERS, "1"); - SDL_Init(SDL_INIT_VIDEO); + SDL_Init(SDL_INIT_VIDEO | SDL_INIT_AUDIO); } // Window @@ -263,6 +274,246 @@ bare_sdl_get_event_key_scancode( return key->handle.scancode; } +static uint32_t +bare_sdl_open_audio_device( + js_env_t *env, + js_receiver_t, + uint32_t requested_device_id, + std::optional format, + std::optional channels, + std::optional freq +) { + int err; + + SDL_AudioSpec spec; + SDL_AudioSpec *spec_ptr = nullptr; + if (format.has_value() && channels.has_value() && freq.has_value()) { + spec = { + .format = static_cast(format.value()), + .channels = channels.value(), + .freq = freq.value() + }; + + spec_ptr = &spec; + } + + auto logical_device_id = SDL_OpenAudioDevice((SDL_AudioDeviceID) requested_device_id, spec_ptr); + + if (logical_device_id == 0) { + err = js_throw_error(env, nullptr, SDL_GetError()); + assert(err == 0); + + throw js_pending_exception; + } + + return logical_device_id; +} + +static void +bare_sdl_close_audio_device( + js_env_t *, + js_receiver_t, + uint32_t device_id +) { + SDL_CloseAudioDevice((SDL_AudioDeviceID) device_id); +} + +static bool +bare_sdl_pause_audio_device( + js_env_t *env, + js_receiver_t, + uint32_t device_id +) { + return SDL_PauseAudioDevice(device_id); +} + +static bool +bare_sdl_resume_audio_device( + js_env_t *env, + js_receiver_t, + uint32_t device_id +) { + return SDL_ResumeAudioDevice(device_id); +} + +static std::vector +bare_sdl_get_audio_playback_devices( + js_env_t *env, + js_receiver_t +) { + int count = 0; + SDL_AudioDeviceID *devices = SDL_GetAudioPlaybackDevices(&count); + + std::vector list; + + if (devices != nullptr) { + for (int i = 0; i < count; i++) { + list.push_back(devices[i]); + } + SDL_free(devices); + } + + return list; +} + +static std::vector +bare_sdl_get_audio_recording_devices( + js_env_t *env, + js_receiver_t +) { + int count = 0; + SDL_AudioDeviceID *devices = SDL_GetAudioRecordingDevices(&count); + + std::vector list; + + if (devices != nullptr) { + for (int i = 0; i < count; i++) { + list.push_back(devices[i]); + } + SDL_free(devices); + } + + return list; +} + +static std::optional +bare_sdl_get_audio_device_list_item( + js_env_t *env, + js_receiver_t, + js_arraybuffer_span_of_t list, + int index +) { + if (index < 0 || index >= list->count || !list->devices) { + return std::nullopt; + } + + return uint32_t(list->devices[index]); +} + +static std::optional +bare_sdl_get_audio_device_name( + js_env_t *env, + js_receiver_t, + uint32_t device_id +) { + const char *name = SDL_GetAudioDeviceName((SDL_AudioDeviceID) device_id); + return name; +} + +static double +bare_sdl_get_audio_device_gain( + js_env_t *env, + js_receiver_t, + uint32_t device_id +) { + return SDL_GetAudioDeviceGain((SDL_AudioDeviceID) device_id); +} + +static void +bare_sdl_set_audio_device_gain( + js_env_t *env, + js_receiver_t, + uint32_t device_id, + double gain +) { + SDL_SetAudioDeviceGain((SDL_AudioDeviceID) device_id, (float) gain); +} + +static js_arraybuffer_t +bare_sdl_get_audio_device_format( + js_env_t *env, + js_receiver_t, + uint32_t device_id +) { + int err; + + js_arraybuffer_t handle; + + bare_sdl_audio_device_format_t *format; + err = js_create_arraybuffer(env, format, handle); + assert(err == 0); + + format->valid = SDL_GetAudioDeviceFormat( + (SDL_AudioDeviceID) device_id, + &format->spec, + &format->sample_frames + ); + + return handle; +} + +static bool +bare_sdl_get_audio_device_format_valid( + js_env_t *env, + js_receiver_t, + js_arraybuffer_span_of_t format +) { + return format->valid; +} + +static uint32_t +bare_sdl_get_audio_device_format_format( + js_env_t *env, + js_receiver_t, + js_arraybuffer_span_of_t format +) { + return (uint32_t) format->spec.format; +} + +static int +bare_sdl_get_audio_device_format_channels( + js_env_t *env, + js_receiver_t, + js_arraybuffer_span_of_t format +) { + return format->spec.channels; +} + +static int +bare_sdl_get_audio_device_format_freq( + js_env_t *env, + js_receiver_t, + js_arraybuffer_span_of_t format +) { + return format->spec.freq; +} + +static int +bare_sdl_get_audio_device_format_sample_frames( + js_env_t *env, + js_receiver_t, + js_arraybuffer_span_of_t format +) { + return format->sample_frames; +} + +static bool +bare_sdl_is_audio_device_physical( + js_env_t *env, + js_receiver_t, + uint32_t deviceId +) { + return SDL_IsAudioDevicePhysical((SDL_AudioDeviceID) deviceId); +} + +static bool +bare_sdl_is_audio_device_playback( + js_env_t *env, + js_receiver_t, + uint32_t deviceId +) { + return SDL_IsAudioDevicePlayback((SDL_AudioDeviceID) deviceId); +} + +static bool +bare_sdl_is_audio_device_paused( + js_env_t *env, + js_receiver_t, + uint32_t deviceId +) { + return SDL_AudioDevicePaused((SDL_AudioDeviceID) deviceId); +} + // Exports static js_value_t * @@ -690,6 +941,21 @@ bare_sdl_exports(js_env_t *env, js_value_t *exports) { V(SDL_SCANCODE_ENDCALL) V(SDL_SCANCODE_RESERVED) V(SDL_SCANCODE_COUNT) + + V(SDL_AUDIO_U8) + V(SDL_AUDIO_S8) + V(SDL_AUDIO_S16LE) + V(SDL_AUDIO_S16BE) + V(SDL_AUDIO_S32LE) + V(SDL_AUDIO_S32BE) + V(SDL_AUDIO_F32LE) + V(SDL_AUDIO_F32BE) + V(SDL_AUDIO_S16) + V(SDL_AUDIO_S32) + V(SDL_AUDIO_F32) + + V(SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK) + V(SDL_AUDIO_DEVICE_DEFAULT_RECORDING) #undef V #define V(name, function) \ @@ -714,6 +980,26 @@ bare_sdl_exports(js_env_t *env, js_value_t *exports) { V("getEventType", bare_sdl_get_event_type) V("getEventKey", bare_sdl_get_event_key) V("getEventKeyScancode", bare_sdl_get_event_key_scancode) + + V("openAudioDevice", bare_sdl_open_audio_device) + V("closeAudioDevice", bare_sdl_close_audio_device) + V("pauseAudioDevice", bare_sdl_pause_audio_device) + V("resumeAudioDevice", bare_sdl_resume_audio_device) + V("getAudioPlaybackDevices", bare_sdl_get_audio_playback_devices) + V("getAudioRecordingDevices", bare_sdl_get_audio_recording_devices) + V("getAudioDeviceListItem", bare_sdl_get_audio_device_list_item) + V("getAudioDeviceName", bare_sdl_get_audio_device_name) + V("getAudioDeviceGain", bare_sdl_get_audio_device_gain) + V("setAudioDeviceGain", bare_sdl_set_audio_device_gain) + V("getAudioDeviceFormat", bare_sdl_get_audio_device_format) + V("getAudioDeviceFormatValid", bare_sdl_get_audio_device_format_valid) + V("getAudioDeviceFormatFormat", bare_sdl_get_audio_device_format_format) + V("getAudioDeviceFormatChannels", bare_sdl_get_audio_device_format_channels) + V("getAudioDeviceFormatFreq", bare_sdl_get_audio_device_format_freq) + V("getAudioDeviceFormatSampleFrames", bare_sdl_get_audio_device_format_sample_frames) + V("isAudioDevicePhysical", bare_sdl_is_audio_device_physical) + V("isAudioDevicePlayback", bare_sdl_is_audio_device_playback) + V("isAudioDevicePaused", bare_sdl_is_audio_device_paused) #undef V return exports; diff --git a/examples/audio-device-list.js b/examples/audio-device-list.js new file mode 100644 index 0000000..4da9db7 --- /dev/null +++ b/examples/audio-device-list.js @@ -0,0 +1,29 @@ +const sdl = require('..') + +async function main() { + const recording = await sdl.AudioDevice.recordingDevices() + const playback = await sdl.AudioDevice.playbackDevices() + + console.log('\n# Recording devices:\n') + for (const device of recording) { + printDevice(device) + } + + console.log('\n# Playback devices:\n') + for (const device of recording) { + printDevice(device) + } +} + +function printDevice(device) { + console.log('Device:', device.name) + console.log('ID:', device.id) + console.log('Sample frames:', device.format.sampleFrames) + console.log('Spec:') + console.log(' Format:', device.format.spec.format) + console.log(' Channels:', device.format.spec.channels) + console.log(' Freq:', device.format.spec.freq) + console.log() +} + +main().catch(console.error) diff --git a/example/package.json b/examples/package.json similarity index 85% rename from example/package.json rename to examples/package.json index d068f50..4646ab3 100644 --- a/example/package.json +++ b/examples/package.json @@ -4,7 +4,7 @@ "author": "Holepunch", "license": "Apache-2.0", "dependencies": { - "bare-ffmpeg": "1.0.0-8", + "bare-ffmpeg": "1.0.0-14", "bare-sdl": "file:.." } } diff --git a/example/video-playback.js b/examples/video-playback.js similarity index 100% rename from example/video-playback.js rename to examples/video-playback.js diff --git a/index.js b/index.js index c4cc70d..9ee2b40 100644 --- a/index.js +++ b/index.js @@ -1,4 +1,5 @@ exports.constants = require('./lib/constants') +exports.AudioDevice = require('./lib/audio-device') exports.Event = require('./lib/event') exports.Poller = require('./lib/poller') exports.Renderer = require('./lib/renderer') diff --git a/lib/audio-device.js b/lib/audio-device.js new file mode 100644 index 0000000..c28df38 --- /dev/null +++ b/lib/audio-device.js @@ -0,0 +1,174 @@ +const binding = require('../binding') +const constants = binding.constants + +class SDLAudioSpec { + constructor(format) { + this._format = format + } + + get format() { + return binding.getAudioDeviceFormatFormat(this._format._handle) + } + + get channels() { + return binding.getAudioDeviceFormatChannels(this._format._handle) + } + + get freq() { + return binding.getAudioDeviceFormatFreq(this._format._handle) + } + + toJSON() { + return { + format: this.format, + channels: this.channels, + freq: this.freq + } + } +} + +class SDLAudioDeviceFormat { + constructor(deviceId) { + this._handle = binding.getAudioDeviceFormat(deviceId) + } + + get valid() { + return binding.getAudioDeviceFormatValid(this._handle) + } + + get sampleFrames() { + return binding.getAudioDeviceFormatSampleFrames(this._handle) + } + + get spec() { + return new SDLAudioSpec(this) + } + + toJSON() { + return { + valid: this.valid, + sampleFrames: this.sampleFrames, + spec: this.spec.toJSON() + } + } +} + +class SDLAudioDevice { + static AudioSpec = SDLAudioSpec + static AudioDeviceFormat = SDLAudioDeviceFormat + + static playbackDeviceFormats() { + const list = binding.getAudioPlaybackDevices() + return list.map((deviceId) => { + return new SDLAudioDeviceFormat(deviceId) + }) + } + + static recordingDeviceFormats() { + const list = binding.getAudioRecordingDevices() + return list.map((deviceId) => { + return new SDLAudioDeviceFormat(deviceId) + }) + } + + static recordingDevices() { + const list = binding.getAudioRecordingDevices() + return list.map((deviceId) => { + return new SDLAudioDevice(deviceId) + }) + } + + static playbackDevices() { + const list = binding.getAudioPlaybackDevices() + return list.map((deviceId) => { + return new SDLAudioDevice(deviceId) + }) + } + + static defaultRecordingDevice(spec) { + return new SDLAudioDevice( + constants.SDL_AUDIO_DEVICE_DEFAULT_RECORDING, + spec + ) + } + + static defaultPlaybackDevice(spec) { + return new SDLAudioDevice(constants.SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK, spec) + } + + constructor(deviceId, spec) { + this._requestedDeviceId = deviceId + this.spec = spec + + const format = this.spec?.format + const channels = this.spec?.channels + const freq = this.spec?.freq + + this.id = binding.openAudioDevice( + this._requestedDeviceId, + format, + channels, + freq + ) + } + + destroy() { + if (this.closing) return + binding.closeAudioDevice(this.id) + this.id = null + } + + [Symbol.dispose]() { + this.destroy() + } + + get name() { + return binding.getAudioDeviceName(this.id) + } + + get format() { + return new SDLAudioDeviceFormat(this.id) + } + + get isPlaybackDevice() { + return binding.isAudioDevicePlayback(this.id) + } + + get isPhysicalDevice() { + return binding.isAudioDevicePhysical(this.id) + } + + get isPaused() { + return binding.isAudioDevicePaused(this.id) + } + + get gain() { + return binding.getAudioDeviceGain(this.id) + } + + set gain(volume) { + binding.setAudioDeviceGain(this.id, volume) + } + + pause() { + return binding.pauseAudioDevice(this.id) + } + + resume() { + return binding.resumeAudioDevice(this.id) + } + + toJSON() { + return { + id: this.id, + name: this.name, + format: this.format, + isPlaybackDevice: this.isPlaybackDevice, + isPhysicalDevice: this.isPhysicalDevice, + isPaused: this.isPaused, + gain: this.gain + } + } +} + +module.exports = SDLAudioDevice diff --git a/package.json b/package.json index 9564938..cfa75f2 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "cmake-fetch": "^1.4.6", "cmake-harden": "^1.1.2", "prettier": "^3.5.3", - "prettier-config-standard": "^7.0.0" + "prettier-config-standard": "^7.0.0", + "which-runtime": "^1.3.0" } } diff --git a/test.js b/test.js index 74c605f..464c6c8 100644 --- a/test.js +++ b/test.js @@ -1,3 +1,4 @@ +require('./test/audio-device') require('./test/event') require('./test/poller') require('./test/renderer') diff --git a/test/audio-device.js b/test/audio-device.js new file mode 100644 index 0000000..ec07b32 --- /dev/null +++ b/test/audio-device.js @@ -0,0 +1,118 @@ +const test = require('brittle') +const { isLinux } = require('which-runtime') +const sdl = require('..') + +test('sdl.AudioDevice - playbackDeviceFormats', (t) => { + const formats = sdl.AudioDevice.playbackDeviceFormats() + t.ok(Array.isArray(formats), 'returns an array') +}) + +test('sdl.AudioDevice - recordingDeviceFormats', (t) => { + const formats = sdl.AudioDevice.recordingDeviceFormats() + t.ok(Array.isArray(formats), 'returns an array') +}) + +test('sdl.AudioDevice - playbackDevices', (t) => { + const devices = sdl.AudioDevice.playbackDevices() + t.ok(Array.isArray(devices), 'returns an array') +}) + +test('sdl.AudioDevice - recordingDevices', (t) => { + const devices = sdl.AudioDevice.recordingDevices() + t.ok(Array.isArray(devices), 'returns an array') +}) + +test('sdl.AudioDevice - defaultRecordingDevice', (t) => { + if (isLinux) { + t.pass() + return + } + + const device = sdl.AudioDevice.defaultRecordingDevice() + t.ok(device instanceof sdl.AudioDevice, 'returns sdl.AudioDevice instance') +}) + +test('sdl.AudioDevice - defaultPlaybackDevice', (t) => { + if (isLinux) { + t.pass() + return + } + + const device = sdl.AudioDevice.defaultPlaybackDevice() + t.ok(device instanceof sdl.AudioDevice, 'returns sdl.AudioDevice instance') +}) + +test('sdl.AudioDevice - properties', (t) => { + if (isLinux) { + t.pass() + return + } + + const spec = { format: sdl.constants.SDL_AUDIO_F32, channels: 2, freq: 48000 } + using device = sdl.AudioDevice.defaultPlaybackDevice(spec) + + t.ok(typeof device.name === 'string') + t.ok(device.format instanceof sdl.AudioDevice.AudioDeviceFormat) + t.is(typeof device.isPlaybackDevice, 'boolean') + t.is(typeof device.isPhysicalDevice, 'boolean') + t.is(typeof device.isPaused, 'boolean') + t.is(typeof device.gain, 'number') +}) + +test('sdl.AudioDevice - set gain', (t) => { + if (isLinux) { + t.pass() + return + } + + using device = sdl.AudioDevice.defaultPlaybackDevice() + device.gain = 0.5 + t.is(device.gain, 0.5, 'sets gain correctly') +}) + +test('sdl.AudioDevice - pause/resume', (t) => { + if (isLinux) { + t.pass() + return + } + + const device = sdl.AudioDevice.defaultPlaybackDevice() + + { + const result = device.pause() + t.ok(typeof result === 'boolean') + } + + { + const result = device.resume() + t.ok(typeof result === 'boolean', 'returns boolean') + } +}) + +test('SDLAudioSpec', (t) => { + if (isLinux) { + t.pass() + return + } + + using device = sdl.AudioDevice.defaultPlaybackDevice() + const format = new sdl.AudioDevice.AudioDeviceFormat(device.id) + const spec = new sdl.AudioDevice.AudioSpec(format) + t.ok(spec && spec._format) +}) + +test('sdl.AudioDeviceFormat', (t) => { + const deviceId = 1 + const format = new sdl.AudioDevice.AudioDeviceFormat(deviceId) + + t.ok(format._handle) + + const valid = format.valid + t.ok(typeof valid === 'boolean', 'returns boolean') + + const sampleFrames = format.sampleFrames + t.ok(typeof sampleFrames === 'number', 'returns number') + + const spec = format.spec + t.ok(spec instanceof sdl.AudioDevice.AudioSpec, 'returns AudioSpec instance') +})