// Copyright (c) 2022 Robin Davies // // Permission is hereby granted, free of charge, to any person obtaining a copy of // this software and associated documentation files (the "Software"), to deal in // the Software without restriction, including without limitation the rights to // use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of // the Software, and to permit persons to whom the Software is furnished to do so, // subject to the following conditions: // // The above copyright notice and this permission notice shall be included in all // copies or substantial portions of the Software. // // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS // FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR // COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER // IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN // CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. import { Theme } from '@mui/material/styles'; import WithStyles from './WithStyles'; import { createStyles } from './WithStyles'; import { withStyles } from "tss-react/mui"; import Button from '@mui/material/Button'; import DialogActions from '@mui/material/DialogActions'; import DialogEx from './DialogEx'; import JackServerSettings from './JackServerSettings'; import JackHostStatus from './JackHostStatus'; import InputLabel from '@mui/material/InputLabel'; import FormControl from '@mui/material/FormControl'; import Select from '@mui/material/Select'; import DialogContent from '@mui/material/DialogContent'; import MenuItem from '@mui/material/MenuItem'; import Typography from '@mui/material/Typography'; import { PiPedalModel, PiPedalModelFactory } from './PiPedalModel'; import IconButtonEx from './IconButtonEx'; import RefreshIcon from '@mui/icons-material/Refresh'; import Checkbox from '@mui/material/Checkbox'; import FormControlLabel from '@mui/material/FormControlLabel'; import AlsaDeviceInfo from './AlsaDeviceInfo'; import ResizeResponsiveComponent from './ResizeResponsiveComponent'; function filterDevices(devices: AlsaDeviceInfo[]): AlsaDeviceInfo[] { return devices.filter(d => { const name = (d.name + ' ' + d.longName).toLowerCase(); return !(name.includes('hdmi') || name.includes('bcm2835') // Pi 4 headphones out. ); }); } function sortDevices(devices: AlsaDeviceInfo[]): AlsaDeviceInfo[] { function category(d: AlsaDeviceInfo): number { if (d.supportsCapture && d.supportsPlayback) return 0; if (d.longName.toLowerCase().includes("usb")) return 1; return 2; } let copy = [...devices]; copy.sort((a, b) => { let ca = category(a); let cb = category(b); if (ca !== cb) return ca - cb; return a.name.localeCompare(b.name); }); return copy; } const MIN_BUFFER_SIZE = 16; const MAX_BUFFER_SIZE = 2048; const valid_buffer_sizes = [16, 32, 48, 64, 128, 256, 512, 1024, 2048]; interface BufferSetting { bufferSize: number; numberOfBuffers: number; }; // empty string used when no valid device is selected - the default. const INVALID_DEVICE_ID = ""; enum WarningDialogType { None, Apply, Ok, }; interface JackServerSettingsDialogState { latencyText: string; jackServerSettings: JackServerSettings; jackHostStatus?: JackHostStatus; alsaDevices?: AlsaDeviceInfo[]; okEnabled: boolean; fullScreen: boolean; compactWidth: boolean; warningDialogType: WarningDialogType; suppressDeviceWarning: boolean; windowSize: { width: number, height: number}; } const styles = (theme: Theme) => createStyles({ formControl: { margin: theme.spacing(1), minWidth: 120, }, selectEmpty: { marginTop: theme.spacing(2), }, inputLabel: { whiteSpace: "nowrap" } }); export interface JackServerSettingsDialogProps extends WithStyles { open: boolean; jackServerSettings: JackServerSettings; onClose: () => void; onApply: (jackServerSettings: JackServerSettings) => void; } function getLatencyText(settings?: JackServerSettings): string { if (!settings) { return "\u00A0"; } if (!settings.sampleRate || !settings.bufferSize || !settings.numberOfBuffers) { return "\u00A0"; } let ms = (settings.bufferSize * settings.numberOfBuffers) / settings.sampleRate * 1000; return ms.toFixed(1) + "ms"; } function getValidBufferCounts(bufferSize: number, alsaDeviceInfo?: AlsaDeviceInfo): number[] { if (!alsaDeviceInfo) { return []; } let result: number[] = []; if (bufferSize * 2 >= alsaDeviceInfo.minBufferSize) { result = [2, 3, 4, 5, 6]; } else { let minBuffers = Math.ceil(alsaDeviceInfo.minBufferSize / bufferSize / 2 - 0.0001); result = [minBuffers * 2, minBuffers * 3, minBuffers * 4, minBuffers * 5, minBuffers * 6]; } for (let i = 0; i < result.length; ++i) { let selectedSize = result[i] * bufferSize; if (selectedSize < alsaDeviceInfo.minBufferSize || selectedSize > alsaDeviceInfo.maxBufferSize) { result.splice(i, 1); --i; } } return result; } function intersectArrays(a: number[], b: number[]): number[] { return a.filter((v) => b.indexOf(v) !== -1); } function getValidBufferCountsMultiple(bufferSize: number, inDevice?: AlsaDeviceInfo, outDevice?: AlsaDeviceInfo): number[] { let c1 = getValidBufferCounts(bufferSize, inDevice); if (!outDevice) return c1; return intersectArrays(c1, getValidBufferCounts(bufferSize, outDevice)); } function getValidBufferSizesMultiple(inDevice?: AlsaDeviceInfo, outDevice?: AlsaDeviceInfo): number[] { let s1 = getValidBufferSizes(inDevice); if (!outDevice) return s1; return intersectArrays(s1, getValidBufferSizes(outDevice)); } function getValidBufferSizes(alsaDeviceInfo?: AlsaDeviceInfo): number[] { if (!alsaDeviceInfo) { return []; } let result: number[] = []; for (let i = 0; i < valid_buffer_sizes.length; i++) { let v = valid_buffer_sizes[i]; if (getValidBufferCounts(v, alsaDeviceInfo).length !== 0) { result.push(v); } } return result; } function getBestBuffers( inDevice: AlsaDeviceInfo | undefined, outDevice: AlsaDeviceInfo | undefined, bufferSize: number, numberOfBuffers: number, bufferSizeChanging: boolean = false ): BufferSetting { if (!inDevice && !outDevice) { return { bufferSize: bufferSize, numberOfBuffers: numberOfBuffers }; } // If the numberOfbuffers is fine as is, don't change the number of Buffers. // set default values. Otherwise, choose the best buffer count from available buffer counts. let validBuffercounts = getValidBufferCountsMultiple(bufferSize, inDevice, outDevice); const minSize = Math.max(inDevice?.minBufferSize ?? MIN_BUFFER_SIZE, outDevice?.minBufferSize ?? MIN_BUFFER_SIZE); const maxSize = Math.min(inDevice?.maxBufferSize ?? MAX_BUFFER_SIZE, outDevice?.maxBufferSize ?? MAX_BUFFER_SIZE); let ix = validBuffercounts.indexOf(numberOfBuffers); if (ix !== -1) { if (bufferSize * numberOfBuffers <= maxSize && bufferSize * numberOfBuffers >= minSize) { return { bufferSize: bufferSize, numberOfBuffers: numberOfBuffers }; } } // if numberOfBuffers is not valid, and the user has just selected a new buffers size, // then choose from available buffer counts. if (bufferSizeChanging) { if (validBuffercounts.length !== 0) { // prefer the one thats divisible by 3. for (let i = 0; i < validBuffercounts.length; ++i) { if (validBuffercounts[i] % 3 === 0) { numberOfBuffers = validBuffercounts[i]; return { bufferSize: bufferSize, numberOfBuffers: numberOfBuffers }; } } numberOfBuffers = validBuffercounts[0]; return { bufferSize: bufferSize, numberOfBuffers: numberOfBuffers }; } } // otherwise select a sensible starting value. // Build candidate sizes with 32 first if present. let validBufferSizes = getValidBufferSizesMultiple(inDevice, outDevice); validBufferSizes.sort((a, b) => a - b); if (validBufferSizes.indexOf(32) !== -1) { validBufferSizes = [32, ...validBufferSizes.filter(v => v !== 32)]; } function tryPick(count: number): BufferSetting | undefined { for (let bs of validBufferSizes) { const counts = getValidBufferCountsMultiple(bs, inDevice, outDevice); if (counts.indexOf(count) !== -1) { if (bs * count >= minSize && bs * count <= maxSize) { return { bufferSize: bs, numberOfBuffers: count }; } } } return undefined; } let result = tryPick(4); if (!result) result = tryPick(3); if (!result) result = tryPick(2); if (result) return result; // Fallback to previous behaviour. if (64 * 3 >= minSize && 64 * 3 <= maxSize) { return { bufferSize: 64, numberOfBuffers: 3 }; } bufferSize = minSize / 2; numberOfBuffers = 4; if (bufferSize * numberOfBuffers > maxSize) { numberOfBuffers = 2; } return { bufferSize: bufferSize, numberOfBuffers: numberOfBuffers }; }; function isOkEnabled(jackServerSettings: JackServerSettings, alsaDevices?: AlsaDeviceInfo[]) { if (!alsaDevices) return false; if (!jackServerSettings.alsaInputDevice || !jackServerSettings.alsaOutputDevice) return false; const inDevice = alsaDevices.find(d => d.id === jackServerSettings.alsaInputDevice && d.supportsCapture); const outDevice = alsaDevices.find(d => d.id === jackServerSettings.alsaOutputDevice && d.supportsPlayback); if (!inDevice || !outDevice) return false; const sampleRates = intersectArrays(inDevice.sampleRates, outDevice.sampleRates); if (sampleRates.indexOf(jackServerSettings.sampleRate) === -1) return false; const deviceBufferSize = jackServerSettings.bufferSize * jackServerSettings.numberOfBuffers; const minSize = Math.max(inDevice.minBufferSize, outDevice.minBufferSize); const maxSize = Math.min(inDevice.maxBufferSize, outDevice.maxBufferSize); if (deviceBufferSize < minSize || deviceBufferSize > maxSize) return false; const validBufferCounts = getValidBufferCountsMultiple(jackServerSettings.bufferSize, inDevice, outDevice); if (validBufferCounts.indexOf(jackServerSettings.numberOfBuffers) === -1) return false; return true; } const JackServerSettingsDialog = withStyles( class extends ResizeResponsiveComponent { model: PiPedalModel; constructor(props: JackServerSettingsDialogProps) { super(props); this.model = PiPedalModelFactory.getInstance(); let suppressWarnings = true; //localStorage.getItem("suppressSeparateDeviceWarning") === "1"; this.state = { latencyText: getLatencyText(props.jackServerSettings), jackServerSettings: props.jackServerSettings.clone(), // invalid, but not nullish alsaDevices: undefined, okEnabled: false, fullScreen: this.getFullScreen(), compactWidth: this.windowSize.width < 600, warningDialogType: WarningDialogType.None, suppressDeviceWarning: suppressWarnings, windowSize: this.windowSize, jackHostStatus: undefined }; } mounted: boolean = false; getFullScreen() { return false; // return document.documentElement.clientWidth < 420 || // document.documentElement.clientHeight < 700; } onWindowSizeChanged(width: number, height: number): void { // super.onWindowSizeChanged(width, height); // const fullScreen = this.getFullScreen(); const compactWidth = width < 600; this.setState({ compactWidth: compactWidth, windowSize: { width: width, height: height } }); } requestAlsaInfo() { this.model.getAlsaDevices() .then((devices) => { if (this.mounted) { if (this.props.open) { let f = filterDevices(devices); let settings = this.applyAlsaDevices(this.state.jackServerSettings, f); this.setState({ alsaDevices: f, jackServerSettings: settings, latencyText: getLatencyText(settings), okEnabled: isOkEnabled(settings, f) }); } else { this.setState({ alsaDevices: filterDevices(devices) }); } } }) .catch((error) => { console.log("Failed to get ALSA device info: " + error.message); }); } applyAlsaDevices(jackServerSettings: JackServerSettings, alsaDevices?: AlsaDeviceInfo[]) { let result = jackServerSettings.clone(); if (!alsaDevices || alsaDevices.length === 0) { result.valid = false; result.alsaInputDevice = INVALID_DEVICE_ID; result.alsaOutputDevice = INVALID_DEVICE_ID; return result; } let devices = filterDevices(alsaDevices); let inDevice = devices.find(d => d.id === result.alsaInputDevice && d.supportsCapture); let outDevice = devices.find(d => d.id === result.alsaOutputDevice && d.supportsPlayback); if (!inDevice || !outDevice) { const capture = devices.filter(d => d.supportsCapture); const playback = devices.filter(d => d.supportsPlayback); if (capture.length === 1 && playback.length === 1) { inDevice = capture[0]; outDevice = playback[0]; result.alsaInputDevice = inDevice.id; result.alsaOutputDevice = outDevice.id; } else if (capture.length === 1 && capture[0].supportsPlayback && playback.length === 0) { inDevice = capture[0]; outDevice = capture[0]; result.alsaInputDevice = inDevice.id; result.alsaOutputDevice = outDevice.id; } } if (!inDevice || !outDevice) { result.valid = false; if (!inDevice) result.alsaInputDevice = INVALID_DEVICE_ID; if (!outDevice) result.alsaOutputDevice = INVALID_DEVICE_ID; return result; } let sampleRates = intersectArrays(inDevice.sampleRates, outDevice.sampleRates); if (sampleRates.length !== 0 && sampleRates.indexOf(result.sampleRate) === -1) { let bestSr = sampleRates[0]; let bestErr = 1e36; for (let sr of sampleRates) { let err = (sr - result.sampleRate) * (sr - result.sampleRate); if (err < bestErr) { bestErr = err; bestSr = sr; } } result.sampleRate = bestSr; } let bestBuffers = getBestBuffers(inDevice, outDevice, result.bufferSize, result.numberOfBuffers); result.bufferSize = bestBuffers.bufferSize; result.numberOfBuffers = bestBuffers.numberOfBuffers; return result; } tick() { this.model.getJackStatus() .then((jackStatus) => this.setState( { jackHostStatus: jackStatus }) ) .catch((error) => { }); } componentDidMount() { super.componentDidMount(); this.mounted = true; this.updateActive(); } private active: boolean = false; private timerHandle: number | null = null; updateActive() { let active = this.mounted && this.props.open; if (active !== this.active) { this.active = active; if (this.active) { this.setState({ jackServerSettings: this.props.jackServerSettings.clone(), alsaDevices: undefined, latencyText: "", jackHostStatus: undefined, okEnabled: false }); this.requestAlsaInfo(); this.timerHandle = setInterval(() => this.tick(), 1000); } else { if (this.timerHandle !== null) { clearInterval(this.timerHandle); this.timerHandle = null; } } } } componentDidUpdate(oldProps: JackServerSettingsDialogProps) { this.updateActive(); } componentWillUnmount() { super.componentWillUnmount(); this.mounted = false; this.updateActive(); } getSelectedDevice(deviceId: string): AlsaDeviceInfo | undefined { if (!this.state.alsaDevices) return undefined; for (let i = 0; i < this.state.alsaDevices.length; ++i) { if (this.state.alsaDevices[i].id === deviceId) { return this.state.alsaDevices[i]; } } return undefined; } handleRateChanged(e: any) { let rate = e.target.value as number; let inDev = this.getSelectedDevice(this.state.jackServerSettings.alsaInputDevice); let outDev = this.getSelectedDevice(this.state.jackServerSettings.alsaOutputDevice); if (!inDev && !outDev) return; let settings = this.state.jackServerSettings.clone(); settings.sampleRate = rate; settings.valid = false; this.setState({ jackServerSettings: settings, latencyText: getLatencyText(settings), okEnabled: isOkEnabled(settings, this.state.alsaDevices) }); } handleSizeChanged(e: any) { let size = e.target.value as number; let inDev = this.getSelectedDevice(this.state.jackServerSettings.alsaInputDevice); let outDev = this.getSelectedDevice(this.state.jackServerSettings.alsaOutputDevice); if (!inDev && !outDev) return; let settings = this.state.jackServerSettings.clone(); settings.bufferSize = size; settings.valid = false; let bestBufferSetting = getBestBuffers(inDev, outDev, settings.bufferSize, settings.numberOfBuffers, true); settings.bufferSize = bestBufferSetting.bufferSize; settings.numberOfBuffers = bestBufferSetting.numberOfBuffers; this.setState({ jackServerSettings: settings, latencyText: getLatencyText(settings), okEnabled: isOkEnabled(settings, this.state.alsaDevices) }); } handleNumberOfBuffersChanged(e: any) { let bufferCount = e.target.value as number; let inDev = this.getSelectedDevice(this.state.jackServerSettings.alsaInputDevice); let outDev = this.getSelectedDevice(this.state.jackServerSettings.alsaOutputDevice); if (!inDev && !outDev) return; let settings = this.state.jackServerSettings.clone(); settings.numberOfBuffers = bufferCount; settings.valid = false; this.setState({ jackServerSettings: settings, latencyText: getLatencyText(settings), okEnabled: isOkEnabled(settings, this.state.alsaDevices) }); } applySettings() { const settings = this.state.jackServerSettings.clone(); settings.valid = true; this.props.onApply(settings); } handleApply() { if (!this.state.okEnabled) return; if (this.state.jackServerSettings.alsaInputDevice !== this.state.jackServerSettings.alsaOutputDevice && this.state.suppressDeviceWarning === false) { this.setState({ warningDialogType: WarningDialogType.Apply }); } else { this.applySettings(); }; } handleOk() { if (!this.state.okEnabled) return; if (this.state.jackServerSettings.alsaInputDevice !== this.state.jackServerSettings.alsaOutputDevice && this.state.suppressDeviceWarning === false) { this.setState({ warningDialogType: WarningDialogType.Ok }); } else { this.applySettings(); this.props.onClose(); // Close the dialog } }; handleWarningProceed() { this.setState({ warningDialogType: WarningDialogType.None }); if (this.state.warningDialogType === WarningDialogType.Ok) { this.applySettings(); this.props.onClose(); // Close the dialog after the warning } else if (this.state.warningDialogType === WarningDialogType.Apply) { this.applySettings(); } } handleWarningCancel() { this.setState({ warningDialogType: WarningDialogType.None }); } handleWarningCheck(e: any, checked: boolean) { this.setState({ suppressDeviceWarning: checked }); localStorage.setItem("suppressSeparateDeviceWarning", checked ? "1" : "0"); } handleInputDeviceChanged(e: any) { const d = e.target.value as string; let s = this.state.jackServerSettings.clone(); s.alsaInputDevice = d; s.valid = false; let settings = this.applyAlsaDevices(s, this.state.alsaDevices); this.setState({ jackServerSettings: settings, latencyText: getLatencyText(settings), okEnabled: isOkEnabled(settings, this.state.alsaDevices) }); } handleOutputDeviceChanged(e: any) { const d = e.target.value as string; let s = this.state.jackServerSettings.clone(); s.alsaOutputDevice = d; s.valid = false; let settings = this.applyAlsaDevices(s, this.state.alsaDevices); this.setState({ jackServerSettings: settings, latencyText: getLatencyText(settings), okEnabled: isOkEnabled(settings, this.state.alsaDevices) }); } handleClose() { this.props.onClose(); }; render() { const classes = withStyles.getClasses(this.props); const sortedDevices = sortDevices(this.state.alsaDevices ?? []); let selectedInputDevice = this.getSelectedDevice(this.state.jackServerSettings.alsaInputDevice); let selectedOutputDevice = this.getSelectedDevice(this.state.jackServerSettings.alsaOutputDevice); const devicesSelected = (selectedInputDevice && selectedOutputDevice); let bufferSizes: number[] = devicesSelected ? getValidBufferSizesMultiple(selectedInputDevice, selectedOutputDevice) : [this.state.jackServerSettings.bufferSize]; let bufferCounts = devicesSelected ? getValidBufferCountsMultiple(this.state.jackServerSettings.bufferSize, selectedInputDevice, selectedOutputDevice) : [this.state.jackServerSettings.numberOfBuffers ]; let bufferSizeDisabled = !devicesSelected; let bufferCountDisabled = !devicesSelected; let sampleRates = devicesSelected && selectedInputDevice && selectedOutputDevice ? intersectArrays(selectedInputDevice.sampleRates, selectedOutputDevice.sampleRates) : []; let sampleRateOptions = sampleRates.length === 0 && this.state.jackServerSettings.sampleRate ? [this.state.jackServerSettings.sampleRate] : sampleRates; return ( <> { this.handleClose(); }} aria-labelledby="select-channels-title" open={this.props.open} onEnterKey={() => { this.handleOk(); }} fullScreen={this.state.fullScreen} >
{/* Audio Input Device */} Input Device {/* Audio Output Device */} Output Device
this.requestAlsaInfo()} aria-label="refresh-audio-devices">
Sample rate
Buffer size Buffers
Latency: {this.state.latencyText} { JackHostStatus.getDisplayView("Status: ", this.state.jackHostStatus) }
{this.state.warningDialogType !== WarningDialogType.None && ( this.handleWarningProceed()} onClose={() => this.handleWarningCancel()} style={{ userSelect: "none" }} > For best results, you should the same device for both audio input and output. Are you sure you want to continue? this.handleWarningCheck(e, c)} />} label="Don't show me this message again" /> )} ); } }, styles); export default JackServerSettingsDialog;