diff --git a/packages/superdough/superdough.mjs b/packages/superdough/superdough.mjs index 3be97615..41b24d94 100644 --- a/packages/superdough/superdough.mjs +++ b/packages/superdough/superdough.mjs @@ -28,11 +28,13 @@ export const resetLoadedSounds = () => soundMap.set({}); let audioContext; +export const setDefaultAudioContext = () => { + audioContext = new AudioContext(); +}; + export const getAudioContext = () => { if (!audioContext) { - audioContext = new AudioContext(); - const maxChannelCount = audioContext.destination.maxChannelCount; - audioContext.destination.channelCount = maxChannelCount; + setDefaultAudioContext(); } return audioContext; }; @@ -84,15 +86,22 @@ let delays = {}; const maxfeedback = 0.98; let channelMerger, destinationGain; +//update the output channel configuration to match user's audio device +export function initializeAudioOutput() { + const audioContext = getAudioContext(); + const maxChannelCount = audioContext.destination.maxChannelCount; + audioContext.destination.channelCount = maxChannelCount; + channelMerger = new ChannelMergerNode(audioContext, { numberOfInputs: audioContext.destination.channelCount }); + destinationGain = new GainNode(audioContext); + channelMerger.connect(destinationGain); + destinationGain.connect(audioContext.destination); +} // input: AudioNode, channels: ?Array export const connectToDestination = (input, channels = [0, 1]) => { const ctx = getAudioContext(); if (channelMerger == null) { - channelMerger = new ChannelMergerNode(ctx, { numberOfInputs: ctx.destination.channelCount }); - destinationGain = new GainNode(ctx); - channelMerger.connect(destinationGain); - destinationGain.connect(ctx.destination); + initializeAudioOutput(); } //This upmix can be removed if correct channel counts are set throughout the app, // and then strudel could theoretically support surround sound audio files @@ -114,6 +123,7 @@ export const panic = () => { } destinationGain.gain.linearRampToValueAtTime(0, getAudioContext().currentTime + 0.01); destinationGain = null; + channelMerger == null; }; function getDelay(orbit, delaytime, delayfeedback, t) { diff --git a/website/src/repl/Repl.jsx b/website/src/repl/Repl.jsx index 72f57113..98840e43 100644 --- a/website/src/repl/Repl.jsx +++ b/website/src/repl/Repl.jsx @@ -8,6 +8,7 @@ import { code2hash, getDrawContext, logger, silence } from '@strudel.cycles/core import cx from '@src/cx.mjs'; import { transpiler } from '@strudel.cycles/transpiler'; import { getAudioContext, initAudioOnFirstClick, webaudioOutput } from '@strudel.cycles/webaudio'; +import { defaultAudioDeviceName, getAudioDevices, setAudioDevice } from './panel/AudioDeviceSelector'; import { StrudelMirror, defaultSettings } from '@strudel/codemirror'; import { createContext, useCallback, useEffect, useRef, useState } from 'react'; import { @@ -79,7 +80,9 @@ export function Repl({ embedded = false }) { }, bgFill: false, }); + // init settings + initCode().then((decoded) => { let msg; if (decoded) { @@ -118,6 +121,20 @@ export function Repl({ embedded = false }) { editorRef.current?.updateSettings(editorSettings); }, [_settings]); + // on first load, set stored audio device if possible + useEffect(() => { + const { audioDeviceName } = _settings; + if (audioDeviceName !== defaultAudioDeviceName) { + getAudioDevices().then((devices) => { + const deviceID = devices.get(audioDeviceName); + if (deviceID == null) { + return; + } + setAudioDevice(deviceID); + }); + } + }, []); + // // UI Actions // diff --git a/website/src/repl/panel/AudioDeviceSelector.jsx b/website/src/repl/panel/AudioDeviceSelector.jsx new file mode 100644 index 00000000..c2302444 --- /dev/null +++ b/website/src/repl/panel/AudioDeviceSelector.jsx @@ -0,0 +1,74 @@ +import React, { useState } from 'react'; +import { getAudioContext, initializeAudioOutput, setDefaultAudioContext } from '@strudel.cycles/webaudio'; +import { SelectInput } from './SelectInput'; +import { logger } from '@strudel.cycles/core'; + +const initdevices = new Map(); +export const defaultAudioDeviceName = 'System Standard'; + +export const getAudioDevices = async () => { + await navigator.mediaDevices.getUserMedia({ audio: true }); + let mediaDevices = await navigator.mediaDevices.enumerateDevices(); + mediaDevices = mediaDevices.filter((device) => device.kind === 'audiooutput' && device.deviceId !== 'default'); + const devicesMap = new Map(); + devicesMap.set(defaultAudioDeviceName, ''); + mediaDevices.forEach((device) => { + devicesMap.set(device.label, device.deviceId); + }); + return devicesMap; +}; + +export const setAudioDevice = async (id) => { + const audioCtx = getAudioContext(); + if (audioCtx.sinkId === id) { + return; + } + const isValidID = (id ?? '').length > 0; + if (isValidID) { + try { + await audioCtx.setSinkId(id); + } catch { + logger('failed to set audio interface', 'warning'); + } + } else { + // reset the audio context and dont set the sink id if it is invalid AKA System Standard selection + setDefaultAudioContext(); + } + initializeAudioOutput(); +}; + +// Allows the user to select an audio interface for Strudel to play through +export function AudioDeviceSelector({ audioDeviceName, onChange, isDisabled }) { + const [devices, setDevices] = useState(initdevices); + const devicesInitialized = devices.size > 0; + + const onClick = () => { + if (devicesInitialized) { + return; + } + getAudioDevices().then((devices) => { + setDevices(devices); + }); + }; + const onDeviceChange = (deviceName) => { + if (!devicesInitialized) { + return; + } + const deviceID = devices.get(deviceName); + onChange(deviceName); + setAudioDevice(deviceID); + }; + const options = new Map(); + Array.from(devices.keys()).forEach((deviceName) => { + options.set(deviceName, deviceName); + }); + return ( + + ); +} diff --git a/website/src/repl/panel/Panel.jsx b/website/src/repl/panel/Panel.jsx index 3b2b48fe..1c421af2 100644 --- a/website/src/repl/panel/Panel.jsx +++ b/website/src/repl/panel/Panel.jsx @@ -114,7 +114,7 @@ export function Panel({ context }) { {activeFooter === 'console' && } {activeFooter === 'sounds' && } {activeFooter === 'reference' && } - {activeFooter === 'settings' && } + {activeFooter === 'settings' && } {activeFooter === 'files' && } diff --git a/website/src/repl/panel/SelectInput.jsx b/website/src/repl/panel/SelectInput.jsx new file mode 100644 index 00000000..a28c3936 --- /dev/null +++ b/website/src/repl/panel/SelectInput.jsx @@ -0,0 +1,20 @@ +import React from 'react'; +// value: ?ID, options: Map, onChange: ID => null, onClick: event => void, isDisabled: boolean +export function SelectInput({ value, options, onChange, onClick, isDisabled }) { + return ( + + ); +} diff --git a/website/src/repl/panel/SettingsTab.jsx b/website/src/repl/panel/SettingsTab.jsx index 9297b0e4..dc221300 100644 --- a/website/src/repl/panel/SettingsTab.jsx +++ b/website/src/repl/panel/SettingsTab.jsx @@ -1,6 +1,7 @@ import { defaultSettings, settingsMap, useSettings } from '../../settings.mjs'; import { themes } from '@strudel/codemirror'; import { ButtonGroup } from './Forms.jsx'; +import { AudioDeviceSelector } from './AudioDeviceSelector.jsx'; function Checkbox({ label, value, onChange }) { return ( @@ -72,7 +73,7 @@ const fontFamilyOptions = { mode7: 'mode7', }; -export function SettingsTab() { +export function SettingsTab({ started }) { const { theme, keybindings, @@ -86,10 +87,20 @@ export function SettingsTab() { fontSize, fontFamily, panelPosition, + audioDeviceName, } = useSettings(); return (
+ {AudioContext.prototype.setSinkId != null && ( + + settingsMap.setKey('audioDeviceName', audioDeviceName)} + /> + + )} settingsMap.setKey('theme', theme)} /> diff --git a/website/src/settings.mjs b/website/src/settings.mjs index f3ceeb6d..c7413636 100644 --- a/website/src/settings.mjs +++ b/website/src/settings.mjs @@ -2,6 +2,7 @@ import { persistentMap, persistentAtom } from '@nanostores/persistent'; import { useStore } from '@nanostores/react'; import { register } from '@strudel.cycles/core'; import * as tunes from './repl/tunes.mjs'; +import { defaultAudioDeviceName } from './repl/panel/AudioDeviceSelector'; import { logger } from '@strudel.cycles/core'; export const defaultSettings = { @@ -22,6 +23,7 @@ export const defaultSettings = { soundsFilter: 'all', panelPosition: 'right', userPatterns: '{}', + audioDeviceName: defaultAudioDeviceName, }; export const settingsMap = persistentMap('strudel-settings', defaultSettings);