feat(player): add AAudio hi-res, WavPack DSD and DAC volume

This commit is contained in:
zarzet committed 2026-09-27 15:26:11 +07:00
1 parent 6decce0f8b
commit d92bcf7716
32 files changed
+2118 -55

No files matched your search

@@ -1,8 +1,9 @@
# Direct USB audio
Android USB host transport for local FLAC/WAV PCM and uncompressed DSF/DSDIFF.
Android USB host transport for local FLAC/WAV PCM, uncompressed DSF/DSDIFF and
lossless WavPack DSD.
The existing Android 14 preferred-mixer path remains available. Direct USB is
opt-in under Settings > Library > Playback > USB bit-perfect audio.
opt-in under Settings > Library > Playback > Hi-res and bit-perfect audio.
## Ownership and playback
@@ -23,13 +24,37 @@ opt-in under Settings > Library > Playback > USB bit-perfect audio.
completed frame. Disconnect pauses playback; it never redirects active USB
playback to the speaker. Pending permission requests can be cancelled by Next.
- ReplayGain, AutoMix, software volume and playback-rate processing are bypassed
while USB mode is selected. Volume is controlled at the DAC.
while USB mode is selected. Volume is controlled at the DAC's USB Feature Unit.
## Hardware volume
- Follow the selected playback terminal to a UAC1/UAC2 Feature Unit. Do not
modify capture controls, guess a mixer/selector route, or advertise volume
when only some channels are writable.
- Read GET_MIN/MAX/RES (UAC1) or RANGE (UAC2), then SET_CUR and read back CUR.
Master volume is preferred; otherwise all playback channels must be writable.
Values are signed 1/256 dB. Gaps/steps round down; positive gain is not offered.
- Before the first audio transfer, lower each channel to at most -40 dB, keeping
quieter or already silent channels. Reuse a user-selected volume between songs
on the same attached device, respecting a hardware knob lowered externally.
Remove remembered levels on detach. This is attenuation, not a guarantee of
safe acoustic output for every amplifier/headphone combination.
- The Mornye player slider and Library playback settings control verified DAC
volume. They never modify DSD/DoP samples or apply software gain. Phone volume
buttons may not affect direct USB output.
- Warn before enabling the mode. A DAC without verified hardware volume stays
paused unless the user explicitly enables fixed-volume output after a second
warning. A failure after direct-USB preparation never redirects to the speaker.
## DSD
`DsdFile.kt` normalizes DSF channel blocks/LSB ordering and DFF byte-interleaved
MSB data without loading the file into memory. Native U32 uses the device's
byte ordering. DoP uses 24-bit words, left-aligned when carried in 32-bit slots.
WavPack 5.9.0 decodes compressed DSD to the same MSB-first bitstream through a
small JNI bridge (`OPEN_DSD_NATIVE`, checksums enabled). It retains its own file
descriptor across SAF lease closure and never converts DSD into PCM. Reads and
seeks are bounded; only mono/stereo DSD64 through DSD512 are accepted.
The transport assigns alternating 05/FA markers across all frames, including
inserted DSD silence. Seeking retains channel/frame alignment.
@@ -46,9 +71,9 @@ settings; no product-name match or assumption that PCM support implies DSD:
DoP is a separate opt-in for a DAC explicitly known to support it; USB audio
descriptors do not announce DoP support. Keep it disabled for ordinary USB
headsets. Unsupported DSD is stopped rather than interpreted as PCM audio.
WavPack DSD, DST compression, DSD-to-PCM conversion and vendor DAP outputs are not
implemented. SAF scanning reads basic DSD format/duration and uses the filename
for metadata; embedded DSD artwork/tags are not yet imported.
DSD DST compression, DSD-to-PCM conversion and vendor-specific internal DAP DSD
commands are not implemented. SAF scanning reads basic DSD format/duration and
uses the filename for metadata; embedded DSD artwork/tags are not yet imported.
## Verification
@@ -57,13 +82,35 @@ for metadata; embedded DSD artwork/tags are not yet imported.
- JVM tests: DSF padding/bit order, DFF channel order, native LE/BE, DoP layout,
seeking, truncated containers and compressed-DST rejection.
- Android instrumentation: absent USB fallback, real native FFI error cleanup,
and integer WAV decode/seek after the original SAF descriptor is closed.
integer WAV decode/seek after the original SAF descriptor is closed, synthetic
WavPack DSD byte equality through native/DoP packing, seek and EOF behavior.
- Hardware volume host tests: UAC1/UAC2 ranges and read-back, failed verification,
silence preservation, independent channel attenuation and playback topology.
- Flutter tests: opt-in settings, transport options, cancellation, stale replies,
DSD failure without PCM fallback, existing playback/queue/DSP behavior.
Physical PCM/DoP/native-DSD output, DAC lock indication, hardware volume and long
playback stability still require real USB hardware. Host tests, emulator tests
and successful ARM32/ARM64 builds do not establish DAC compatibility.
The user reported working physical USB DSP playback with the previous direct
USB build (device model unspecified). New hardware volume, native DSD/DoP and
long playback stability still require device testing. Host tests, emulator
tests and successful ARM32/ARM64 builds do not establish DAC compatibility.
## DAP PCM hi-res
The separate **DAP hi-res (AAudio exclusive)** option uses Oboe 1.11.0 on Android
8.1+. It opens at the source rate/channels and sufficient precision, with Oboe
conversion disabled. Integer PCM is padded losslessly when necessary; float
output is allowed only for <=24-bit sources with exact representation. A bounded
single-producer/single-consumer queue feeds the audio callback without allocation,
locking or file I/O. Hardware timestamps exclude inserted silence from position.
Android may grant Shared when Exclusive was requested. Verify the actual API,
sharing mode, rate, channels and format before reporting active output. If no
matching exclusive stream is available, close it and use ordinary playback.
DSD is rejected before reaching this PCM engine. Disconnect after starting
pauses playback. This does not claim to bypass undocumented vendor DSP.
The current emulator only grants Shared; instrumentation verifies rejection.
Actual exclusive DAP playback and route-specific hardware behavior need a DAP.
## Reference and dependencies
@@ -71,4 +118,7 @@ USB identity/format facts were checked against
[Linux USB audio quirks](https://github.com/torvalds/linux/blob/master/sound/usb/quirks.c).
The Android-only `libusb1-sys` dependency builds its unmodified bundled libusb;
license texts and rebuild information are in `assets/licenses/usb.txt` and in
the app's license registry. No additional audio SDK or decoder library is added.
the app's license registry. Oboe (Apache-2.0) and WavPack (BSD-3-Clause) are built
statically into `libspotiflac_audio.so` for ARM32/ARM64. Their pinned release URLs
and SHA-256 checksums are in `android/app/src/main/cpp/CMakeLists.txt`; licenses
are included in `assets/licenses/oboe.txt` and `assets/licenses/wavpack.txt`.
@@ -8,6 +8,8 @@ mod framing;
#[cfg(target_os = "android")]
#[allow(unsafe_code)] // libusb ownership is confined to this worker module.
mod transport;
#[cfg(any(test, target_os = "android"))]
mod volume;
use std::sync::Arc;
@@ -34,6 +36,17 @@ pub struct UsbOutputFormat {
pub encoding: String,
}
#[derive(uniffi::Record, Clone, Default)]
pub struct UsbHardwareVolume {
pub available: bool,
pub min_db: f64,
pub max_db: f64,
pub current_db: f64,
/// Quietest channel before startup attenuation; restoring a previous UI
/// setting must respect a DAC volume knob that was lowered externally.
pub restore_limit_db: f64,
}
#[derive(uniffi::Object)]
pub struct UsbDirectOutput {
#[cfg(target_os = "android")]
@@ -118,4 +131,21 @@ impl UsbDirectOutput {
#[cfg(target_os = "android")]
self.output.close();
}
pub fn volume(&self) -> UsbHardwareVolume {
#[cfg(target_os = "android")]
return self.output.volume();
#[cfg(not(target_os = "android"))]
unreachable!("Android-only constructor")
}
pub fn set_volume(&self, db: f64) -> Result<UsbHardwareVolume, UsbAudioError> {
#[cfg(target_os = "android")]
return self.output.set_volume(db).map_err(Into::into);
#[cfg(not(target_os = "android"))]
{
let _ = db;
unreachable!("Android-only constructor")
}
}
}
@@ -1,8 +1,9 @@
//! All raw libusb pointers belong to one thread. Transfer buffers remain at
//! stable addresses until callbacks complete, including cancellation on drop.
use super::{
UsbOutputFormat,
UsbHardwareVolume, UsbOutputFormat,
descriptors::{self, Alternate, Endpoint, PacketClock},
volume::{self, Volume},
};
use libusb1_sys as usb;
use std::{
@@ -22,6 +23,13 @@ struct State {
flush: u64,
flushed: u64,
error: Option<String>,
volume: UsbHardwareVolume,
volume_requests: VecDeque<VolumeRequest>,
}
struct VolumeRequest {
db: f64,
deadline: Instant,
reply: mpsc::SyncSender<Result<UsbHardwareVolume, String>>,
}
type Shared = Arc<(Mutex<State>, Condvar)>;
@@ -61,9 +69,14 @@ impl Output {
}
// SAF/device descriptors are opened by Android and stay owned by
// Kotlin until this worker has joined. No device discovery/root.
let result = Session::open(fd, candidates);
let result = Session::open(fd, candidates, &raw);
match result {
Ok(mut session) => {
state.0.lock().unwrap().volume = session
.volume
.as_ref()
.map(Volume::snapshot)
.unwrap_or_default();
let _ = tx.send(Ok(session.format.clone()));
if let Err(error) = session.run(&state) {
let mut s = state.0.lock().unwrap();
@@ -157,6 +170,30 @@ impl Output {
let _ = t.join();
}
}
pub fn volume(&self) -> UsbHardwareVolume {
self.shared.0.lock().unwrap().volume.clone()
}
pub fn set_volume(&self, db: f64) -> Result<UsbHardwareVolume, String> {
let (reply, result) = mpsc::sync_channel(1);
{
let mut s = self.shared.0.lock().unwrap();
if s.closed || s.error.is_some() || !s.volume.available {
return Err("USB hardware volume unavailable".into());
}
if s.volume_requests.len() >= 8 {
return Err("USB volume requests busy".into());
}
s.volume_requests.push_back(VolumeRequest {
db,
deadline: Instant::now() + Duration::from_secs(2),
reply,
});
self.shared.1.notify_all();
}
result
.recv_timeout(Duration::from_secs(4))
.map_err(|_| "USB volume request timed out".to_string())?
}
}
impl Drop for Output {
fn drop(&mut self) {
@@ -270,9 +307,26 @@ struct Session {
slots: Vec<Box<Slot>>,
feedback_slot: Option<Box<Slot>>,
format: UsbOutputFormat,
volume: Option<Volume>,
}
impl volume::Control for Session {
fn transfer(
&self,
input: bool,
request: u8,
value: u16,
index: u16,
data: &mut [u8],
) -> Result<(), String> {
self.control(input, request, value, index, data, false)
}
}
impl Session {
fn open(fd: i32, candidates: Vec<(Alternate, UsbOutputFormat)>) -> Result<Self, String> {
fn open(
fd: i32,
candidates: Vec<(Alternate, UsbOutputFormat)>,
raw: &[u8],
) -> Result<Self, String> {
let mut s = Self {
context: ptr::null_mut(),
handle: ptr::null_mut(),
@@ -285,6 +339,7 @@ impl Session {
slots: vec![],
feedback_slot: None,
format: candidates[0].1.clone(),
volume: None,
};
unsafe {
// NO_DEVICE_DISCOVERY is required on Android; the USB permission
@@ -313,6 +368,13 @@ impl Session {
if let Some(ep) = &s.feedback {
s.feedback_slot = Some(Slot::new(1, ep.max_packet)?);
}
s.volume = volume::feature(
raw,
s.alternate.control,
s.alternate.terminal,
s.format.channels,
)
.and_then(|feature| Volume::open(&s, feature).ok());
return Ok(s);
}
Err(failure)
@@ -472,6 +534,28 @@ impl Session {
if s.closed {
break;
}
if let Some(request) = s.volume_requests.pop_front() {
drop(s);
if Instant::now() > request.deadline {
let _ = request.reply.send(Err("USB volume request expired".into()));
} else {
let mut volume = self
.volume
.take()
.ok_or("USB hardware volume unavailable")?;
let result = volume.set(self, request.db);
self.volume = Some(volume);
if let Ok(snapshot) = &result {
shared.0.lock().unwrap().volume = snapshot.clone();
}
let error = result.as_ref().err().cloned();
let _ = request.reply.send(result);
if let Some(error) = error {
return Err(error);
}
}
s = shared.0.lock().unwrap();
}
if s.flush != s.flushed {
drop(s);
self.drain();
@@ -0,0 +1,460 @@
//! USB Audio Feature Unit volume. Values are signed 1/256 dB, not PCM gain.
use super::UsbHardwareVolume;
use std::collections::HashMap;
pub trait Control {
fn transfer(
&self,
input: bool,
request: u8,
value: u16,
index: u16,
data: &mut [u8],
) -> Result<(), String>;
}
#[derive(Clone, Debug, PartialEq)]
pub struct Feature {
pub unit: u8,
pub interface: u8,
pub uac2: bool,
pub channels: Vec<u8>,
}
/// Follow only an unambiguous playback terminal chain. Never change a capture
/// Feature Unit or guess which input of a mixer/selector is currently routed.
pub fn feature(raw: &[u8], interface: u8, terminal: u8, channels: u8) -> Option<Feature> {
let mut active = false;
let mut uac2 = false;
let mut sources = HashMap::new();
let mut features = HashMap::new();
let mut outputs = Vec::new();
let mut offset = 0;
while offset + 2 <= raw.len() {
let len = raw[offset] as usize;
if len < 2 || len > raw.len() - offset {
return None;
}
let d = &raw[offset..offset + len];
offset += len;
if d[1] == 4 && len >= 9 {
active = d[2] == interface && d[5] == 1 && d[6] == 1;
uac2 = d[7] == 0x20;
} else if active && d[1] == 0x24 && len >= 4 {
match d[2] {
3 if len >= 9 => {
sources.insert(d[3], d[7]);
outputs.push(d[3]);
}
6 if len >= 7 => {
sources.insert(d[3], d[4]);
let size = if uac2 { 4 } else { d[5] as usize };
let start = if uac2 { 5 } else { 6 };
if size == 0 || size > 4 || len < start + size + 1 {
continue;
}
let writable = |channel: usize| {
let pos = start + channel * size;
if pos + size >= len {
return false;
}
if uac2 {
(d[pos] >> 2) & 3 == 3
} else {
d[pos] & 2 != 0
}
};
let controlled = if writable(0) {
vec![0]
} else if (1..=channels as usize).all(writable) {
(1..=channels).collect()
} else {
continue;
};
features.insert(
d[3],
Feature {
unit: d[3],
interface,
uac2,
channels: controlled,
},
);
}
_ => {}
}
}
}
let mut matches = Vec::new();
for mut id in outputs {
let mut seen = Vec::new();
let mut candidate = None;
while id != terminal && !seen.contains(&id) {
seen.push(id);
if candidate.is_none() {
candidate = features.get(&id).cloned();
}
id = match sources.get(&id) {
Some(source) => *source,
None => break,
};
}
if id == terminal
&& let Some(f) = candidate
{
matches.push(f);
}
}
matches.dedup();
if matches.len() == 1 {
matches.pop()
} else {
None
}
}
#[derive(Clone, Copy, Debug)]
struct Range {
min: i16,
max: i16,
step: i16,
}
impl Range {
fn parse(bytes: &[u8]) -> Result<Self, String> {
let r = Self {
min: i16::from_le_bytes([bytes[0], bytes[1]]),
max: i16::from_le_bytes([bytes[2], bytes[3]]),
step: i16::from_le_bytes([bytes[4], bytes[5]]),
};
if r.min == i16::MIN || r.min > r.max || r.step < 0 {
return Err("Invalid USB volume range".into());
}
Ok(r)
}
fn floor(self, value: i32) -> i16 {
let value = value.clamp(self.min as i32, self.max as i32);
let step = i32::from(self.step).max(1);
(self.min as i32 + (value - self.min as i32) / step * step) as i16
}
}
pub struct Volume {
feature: Feature,
ranges: Vec<Vec<Range>>,
snapshot: UsbHardwareVolume,
}
impl Volume {
pub fn open(control: &impl Control, feature: Feature) -> Result<Self, String> {
let index = (feature.unit as u16) << 8 | feature.interface as u16;
let mut ranges = Vec::new();
let mut current = i16::MIN;
let mut startup = Vec::new();
let mut restore_limit = i16::MAX;
for channel in &feature.channels {
let selector = 0x200 | *channel as u16;
let mut data = [0; 2];
control.transfer(
true,
if feature.uac2 { 1 } else { 0x81 },
selector,
index,
&mut data,
)?;
current = current.max(i16::from_le_bytes(data));
restore_limit = restore_limit.min(i16::from_le_bytes(data));
startup.push(i32::from(i16::from_le_bytes(data).min(-40 * 256)));
let r = if feature.uac2 {
control.transfer(true, 2, selector, index, &mut data)?;
let count = u16::from_le_bytes(data) as usize;
if !(1..=16).contains(&count) {
return Err("Unsupported USB volume ranges".into());
}
let mut data = vec![0; 2 + 6 * count];
control.transfer(true, 2, selector, index, &mut data)?;
if u16::from_le_bytes([data[0], data[1]]) as usize != count {
return Err("USB volume range changed".into());
}
data[2..]
.as_chunks::<6>()
.0
.iter()
.map(|bytes| Range::parse(bytes))
.collect::<Result<Vec<_>, _>>()?
} else {
let mut data = [0; 6];
for (request, bytes) in (0x82..=0x84).zip(data.as_chunks_mut::<2>().0) {
control.transfer(true, request, selector, index, bytes)?;
}
vec![Range::parse(&data)?]
};
ranges.push(r);
}
let min = ranges
.iter()
.map(|r| r.iter().map(|r| r.min).min().unwrap())
.max()
.unwrap();
let max = ranges
.iter()
.map(|r| r.iter().map(|r| r.max).max().unwrap())
.min()
.unwrap()
.min(0);
if min >= max || min > -40 * 256 {
return Err("USB volume cannot provide startup attenuation".into());
}
let mut volume = Self {
feature,
ranges,
snapshot: UsbHardwareVolume {
available: true,
min_db: min as f64 / 256.0,
max_db: max as f64 / 256.0,
current_db: current as f64 / 256.0,
restore_limit_db: restore_limit as f64 / 256.0,
},
};
// Never raise the device's current volume on connection. Read-back is
// mandatory before any audio transfer can start.
volume.set_targets(control, &startup)?;
Ok(volume)
}
pub fn snapshot(&self) -> UsbHardwareVolume {
self.snapshot.clone()
}
pub fn set(&mut self, control: &impl Control, db: f64) -> Result<UsbHardwareVolume, String> {
if !db.is_finite() || db > 0.0 || db < -128.0 {
return Err("Invalid USB volume".into());
}
let target = (db.min(self.snapshot.max_db) * 256.0).floor() as i32;
self.set_targets(control, &vec![target; self.feature.channels.len()])
}
fn set_targets(
&mut self,
control: &impl Control,
targets: &[i32],
) -> Result<UsbHardwareVolume, String> {
let index = (self.feature.unit as u16) << 8 | self.feature.interface as u16;
let mut loudest = i16::MIN;
for ((channel, ranges), target) in self
.feature
.channels
.iter()
.zip(&self.ranges)
.zip(targets.iter().copied())
{
// -32768 is USB's negative-infinity value. Preserve a channel that
// was already silent; startup must never raise a quieter channel.
let value = if target == i16::MIN as i32 {
i16::MIN
} else {
ranges
.iter()
.filter(|r| r.min as i32 <= target)
.map(|r| r.floor(target))
.max()
.ok_or("USB volume below supported range")?
};
let selector = 0x200 | *channel as u16;
control.transfer(false, 1, selector, index, &mut value.to_le_bytes())?;
let mut readback = [0; 2];
control.transfer(
true,
if self.feature.uac2 { 1 } else { 0x81 },
selector,
index,
&mut readback,
)?;
let actual = i16::from_le_bytes(readback);
if actual != value {
return Err("USB volume read-back mismatch".into());
}
loudest = loudest.max(actual);
}
self.snapshot.current_db = loudest as f64 / 256.0;
Ok(self.snapshot())
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::cell::Cell;
struct Fake {
db: Cell<i16>,
mismatch: bool,
}
impl Control for Fake {
fn transfer(
&self,
input: bool,
request: u8,
_: u16,
_: u16,
data: &mut [u8],
) -> Result<(), String> {
if !input {
if !self.mismatch {
self.db.set(i16::from_le_bytes([data[0], data[1]]));
}
} else if request == 2 {
let range = [1, 0, 0, 0xa0, 0, 0, 0, 1]; // -96..0 dB, 1 dB steps.
data.copy_from_slice(&range[..data.len()]);
} else {
data.copy_from_slice(&self.db.get().to_le_bytes());
}
Ok(())
}
}
#[test]
fn startup_attenuates_without_raising_and_rounds_down() {
let io = Fake {
db: Cell::new(0),
mismatch: false,
};
let f = Feature {
unit: 2,
interface: 0,
uac2: true,
channels: vec![0],
};
let mut volume = Volume::open(&io, f.clone()).unwrap();
assert_eq!(volume.snapshot().current_db, -40.0);
assert_eq!(volume.set(&io, -25.2).unwrap().current_db, -26.0);
io.db.set(-70 * 256);
assert_eq!(Volume::open(&io, f).unwrap().snapshot().current_db, -70.0);
}
#[test]
fn failed_readback_cannot_report_safe_volume() {
let io = Fake {
db: Cell::new(0),
mismatch: true,
};
assert!(
Volume::open(
&io,
Feature {
unit: 2,
interface: 0,
uac2: true,
channels: vec![0]
}
)
.is_err()
);
}
#[test]
fn startup_preserves_silent_channels() {
let io = Fake {
db: Cell::new(i16::MIN),
mismatch: false,
};
let v = Volume::open(
&io,
Feature {
unit: 2,
interface: 0,
uac2: true,
channels: vec![0],
},
)
.unwrap();
assert_eq!(v.snapshot().current_db, -128.0);
assert_eq!(io.db.get(), i16::MIN);
}
#[test]
fn topology_selects_playback_not_capture() {
let raw = [
9, 4, 0, 0, 0, 1, 1, 0x20, 0, 18, 0x24, 6, 2, 1, 12, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 12, 0x24, 3, 3, 1, 3, 0, 2, 1, 0, 0, 0,
];
assert_eq!(feature(&raw, 0, 1, 2).unwrap().unit, 2);
assert!(feature(&raw, 0, 9, 2).is_none());
assert!(feature(&raw, 1, 1, 2).is_none());
}
#[test]
fn uac1_control_uses_min_max_resolution_and_verified_current() {
struct Uac1(Cell<i16>);
impl Control for Uac1 {
fn transfer(
&self,
input: bool,
request: u8,
value: u16,
index: u16,
data: &mut [u8],
) -> Result<(), String> {
assert_eq!((value, index), (0x200, 0x200));
if !input {
assert_eq!(request, 1);
self.0.set(i16::from_le_bytes([data[0], data[1]]));
} else {
let value: i16 = match request {
0x81 => self.0.get(),
0x82 => -80 * 256,
0x83 => 0,
0x84 => 128,
_ => panic!("Unexpected UAC1 request"),
};
data.copy_from_slice(&value.to_le_bytes());
}
Ok(())
}
}
let raw = [
9, 4, 0, 0, 0, 1, 1, 0, 0, 10, 0x24, 6, 2, 1, 1, 3, 0, 0, 0, 9, 0x24, 3, 3, 1, 3, 0, 2,
0,
];
let control = Uac1(Cell::new(-10 * 256));
let mut volume = Volume::open(&control, feature(&raw, 0, 1, 2).unwrap()).unwrap();
assert_eq!(volume.snapshot().current_db, -40.0);
assert_eq!(volume.set(&control, -23.1).unwrap().current_db, -23.5);
assert!(volume.set(&control, f64::NAN).is_err());
assert!(volume.set(&control, 2.0).is_err());
}
#[test]
fn startup_keeps_independent_channels_at_or_below_their_old_levels() {
struct Stereo([Cell<i16>; 2]);
impl Control for Stereo {
fn transfer(
&self,
input: bool,
request: u8,
value: u16,
_: u16,
data: &mut [u8],
) -> Result<(), String> {
let channel = (value & 255) as usize - 1;
if !input {
self.0[channel].set(i16::from_le_bytes([data[0], data[1]]));
} else if request == 2 {
data.copy_from_slice(&[1, 0, 0, 0xa0, 0, 0, 0, 1][..data.len()]);
} else {
data.copy_from_slice(&self.0[channel].get().to_le_bytes());
}
Ok(())
}
}
let io = Stereo([Cell::new(-70 * 256), Cell::new(-10 * 256)]);
let volume = Volume::open(
&io,
Feature {
unit: 2,
interface: 0,
uac2: true,
channels: vec![1, 2],
},
)
.unwrap();
assert_eq!(io.0[0].get(), -70 * 256);
assert_eq!(io.0[1].get(), -40 * 256);
assert_eq!(volume.snapshot().restore_limit_db, -70.0);
}
}