mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-30 13:19:44 +02:00
feat(player): add AAudio hi-res, WavPack DSD and DAC volume
This commit is contained in:
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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user