Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Audio device selection #854

Merged
merged 16 commits into from
Dec 31, 2023
24 changes: 17 additions & 7 deletions packages/superdough/superdough.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
Expand Down Expand Up @@ -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<int>
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
Expand All @@ -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) {
Expand Down
17 changes: 17 additions & 0 deletions website/src/repl/Repl.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -79,7 +80,9 @@ export function Repl({ embedded = false }) {
},
bgFill: false,
});

// init settings

initCode().then((decoded) => {
let msg;
if (decoded) {
Expand Down Expand Up @@ -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
//
Expand Down
74 changes: 74 additions & 0 deletions website/src/repl/panel/AudioDeviceSelector.jsx
Original file line number Diff line number Diff line change
@@ -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 (
<SelectInput
isDisabled={isDisabled}
options={options}
onClick={onClick}
value={audioDeviceName}
onChange={onDeviceChange}
/>
);
}
2 changes: 1 addition & 1 deletion website/src/repl/panel/Panel.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ export function Panel({ context }) {
{activeFooter === 'console' && <ConsoleTab log={log} />}
{activeFooter === 'sounds' && <SoundsTab />}
{activeFooter === 'reference' && <Reference />}
{activeFooter === 'settings' && <SettingsTab />}
{activeFooter === 'settings' && <SettingsTab started={context.started} />}
{activeFooter === 'files' && <FilesTab />}
</div>
</div>
Expand Down
20 changes: 20 additions & 0 deletions website/src/repl/panel/SelectInput.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import React from 'react';
// value: ?ID, options: Map<ID, any>, onChange: ID => null, onClick: event => void, isDisabled: boolean
export function SelectInput({ value, options, onChange, onClick, isDisabled }) {
return (
<select
disabled={isDisabled}
onClick={onClick}
className="p-2 bg-background rounded-md text-foreground"
value={value ?? ''}
onChange={(e) => onChange(e.target.value)}
>
{options.size == 0 && <option value={value}>{`${value ?? 'select an option'}`}</option>}
{Array.from(options.keys()).map((id) => (
<option key={id} className="bg-background" value={id}>
{options.get(id)}
</option>
))}
</select>
);
}
13 changes: 12 additions & 1 deletion website/src/repl/panel/SettingsTab.jsx
Original file line number Diff line number Diff line change
@@ -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 (
Expand Down Expand Up @@ -72,7 +73,7 @@ const fontFamilyOptions = {
mode7: 'mode7',
};

export function SettingsTab() {
export function SettingsTab({ started }) {
const {
theme,
keybindings,
Expand All @@ -86,10 +87,20 @@ export function SettingsTab() {
fontSize,
fontFamily,
panelPosition,
audioDeviceName,
} = useSettings();

return (
<div className="text-foreground p-4 space-y-4">
{AudioContext.prototype.setSinkId != null && (
<FormItem label="Audio Output Device">
<AudioDeviceSelector
isDisabled={started}
audioDeviceName={audioDeviceName}
onChange={(audioDeviceName) => settingsMap.setKey('audioDeviceName', audioDeviceName)}
/>
</FormItem>
)}
<FormItem label="Theme">
<SelectInput options={themeOptions} value={theme} onChange={(theme) => settingsMap.setKey('theme', theme)} />
</FormItem>
Expand Down
2 changes: 2 additions & 0 deletions website/src/settings.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -22,6 +23,7 @@ export const defaultSettings = {
soundsFilter: 'all',
panelPosition: 'right',
userPatterns: '{}',
audioDeviceName: defaultAudioDeviceName,
};

export const settingsMap = persistentMap('strudel-settings', defaultSettings);
Expand Down