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

[SKIP SOF-TEST] Simple dependency-free ALSA test rig for PCM capture analysis. #1144

Merged
merged 1 commit into from
Jan 10, 2024

Conversation

andyross
Copy link
Contributor

@andyross andyross commented Jan 3, 2024

Just drop this script on a test device to run it. No tools to build, no dependencies to install. Confirmed to run on Python 3.8+ with nothing more than the core libraries and a working libasound.so.2 visible to the runtime linker.

When run without arguments, the tool will record from the capture device for the specified duration, then emit the resulting samples back out the playback device without processing (except potentially to convert the sample format from s32_le to s16_le if needed, and to discard any channels beyond those supported by the playback device).

Passing --chirp-test enables a playback-to-capture latency detector: the tool will emit a short ~6 kHz wave packet via ALSA's mmap interface (which allows measuring and correcting for the buffer latency from the userspace process) and simultaneously loop on short reads from the capture device looking for the moment it arrives.

Passing --echo-test enables a capture-while-playback test. The script will play a specified .wav file ("noise.wav" by default) for the specified duration, while simultaneously capturing, and report the "power" (in essentially arbitrary units, but it's linear with actual signal energy assuming the sample space is itself linear) of the captured data to stdout at the end of the test.

@andyross andyross requested a review from a team as a code owner January 3, 2024 03:49
@andyross
Copy link
Contributor Author

andyross commented Jan 3, 2024

From thesofproject/sof#8681 , see there for previous discussion

marc-hb
marc-hb previously requested changes Jan 3, 2024
Copy link
Collaborator

@marc-hb marc-hb left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A good few, minor codestyle issues left otherwise LGTM but we need a reviewer who knows ALSA well.

Unlike other repos, we run pylint in this one and most of these few warnings seem valid and easy to fix:
https://github.com/thesofproject/sof-test/actions/runs/7393258262/job/20112906366?pr=1144

Please use pylint: disable=W1234 for the ones you can't or don't want to fix.

tools/capture-test.py Outdated Show resolved Hide resolved
tools/capture-test.py Outdated Show resolved Hide resolved
tools/capture-test.py Outdated Show resolved Hide resolved
tools/capture-test.py Outdated Show resolved Hide resolved
tools/capture-test.py Outdated Show resolved Hide resolved
kv2019i
kv2019i previously approved these changes Jan 3, 2024
Copy link
Contributor

@kv2019i kv2019i left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @andyross ! While there is obvious overlap with alsabat and rest of alsa-utils, the "drop-in" capability will for sure have value in many cases.

tools/capture-test.py Outdated Show resolved Hide resolved
tools/capture-test.py Outdated Show resolved Hide resolved
if committed == 0:
break

silence = bytes(2 * opts.chan * ring_frames)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is there any reason to restrict the playback usage to S16_LE?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's all any of the devices I'm using have the capability to emit, basically. Agreed that a more general tool would be nice, but there's cost to code size too.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it's fine to keep S16_LE, it's just not clear if this was intentional due to lack of time or by design due to XYZ restrictions.

alsa.snd_pcm_open(c.byref(pcm), dev, alsa.PCM_STREAM_CAPTURE, 0)
pcm_init_stream(pcm, opts.rate, opts.capchan, fmt, alsa.PCM_ACCESS_RW_INTERLEAVED)
frames_remaining = duration * opts.rate
buf_frames = int(opts.rate / 1000) # 1ms blocks
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

doesn't work for 44.1kHz

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not understanding, rate/1000 gives you the number of samples per millisecond. It's true that 44100 doesn't divide evenly, but the code doesn't care. This is just a conveniently small buffer size to do the analysis with later.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

all I am saying is that there should be a clear point on when the code no longer works.
We've have countless issues with such rounding that work fine in configuration A and break in configuration B.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You'd be OK with an assert that the number of frames is >zero? I could add a "chunk_size" tunable I guess too, I just don't see much value there.

Again, the specific size of these buffers isn't important. All the analysis happens on lists of them and doesn't care about how many samples each buffer contains. They're kept small to (1) provide reasonably fine-grained timestamp behavior, since we can only timestamp between API calls, and (2) avoid doing a big allocation and buffer copy synchronously with the capture, since this is python and I don't want to deal with performance headaches.

tools/capture-test.py Show resolved Hide resolved
buflist.append((t, bytes(buf[0:f * fsz])))
return buflist

# A programmatically-detectable chirp/pop signal for testing latency.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not following which latency you are measuring, does this not add playback+capture continuous latencies together?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually what I'm really measuring is a whitebox test inside AEC, which is doing essentially the same thing this python code is doing to watch the chirp go by. This is here as a general smoke test from userspace. But to be specific: it's measuring the time between the arrival of the chirp signal at the head of the userspace mmap buffer to the return from snd_pcm_readi() that contains it.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not following your explanation, sorry.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. Fill the mmap playback buffer[1] with silence
  2. Start playing
  3. Spin until there is just enough space in the buffer to accept the chirp, write it
  4. Check buffer stats and take timestamp.

Now we know exactly how many samples are "ahead" of the chirp but still in host memory, so we can subtract them from the measured time it takes the chirp to appear on the capture stream. You can't do this with snd_pcm_writei() as the buffering is internal to the userspace API.

[1] Which is obviously the actual DMA buffer, though the API doesn't promise this.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not all DMAs provide a precise position, it's a recurring problem with audio - and it's only made worse with the bursty nature of the fimware-controlled DMAs.

You probably have at least one ms of error each way for the Intel HDaudio hw. no idea for others.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No doubt. But we do what we can. The point was just to not have an unmeasurable 0-85ms of latency in the userspace process, or to have to guess at "how long after the first writei() call does the DMA stream start running", etc... These numbers aren't the whole story, but they're at least reliable.

FWIW: I'm getting a pretty consistent 30-31ms round trip on MTL with AEC enabled, which seems not totally unreasonable (10ms buffer for AEC alone, plus 0-1ms getting into and out of the DP thread due to scheduling cadence mismatch) but also that it probably has room for improvement. mt8195 is a disaster in comparison: 100ms on the device I'm using, which AFAICS isn't in the firmware at all but in some hardware component (you can also hear it squash short chirp bursts, which is why the default length is so large).

Copy link
Contributor

@kv2019i kv2019i Jan 8, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is ok with suitable disclaimers. E.g. the 30-31ms number sounds in the right ballpark and matches data got (without AEC) on with ALSA latency tester https://www.alsa-project.org/wiki/Test_latency.c and jack-delay https://wiki.archlinux.org/title/Professional_audio#Latency_verification . Both tools have more dependencies (so don't fit the usage this tool aims to cover), but can be used on any Linux setup to verify the numbers (for a given platform).

tools/capture-test.py Show resolved Hide resolved
@andyross andyross force-pushed the capture-test branch 2 times, most recently from e41a4ab to 0563fab Compare January 3, 2024 17:30
@marc-hb marc-hb dismissed their stale review January 3, 2024 20:54

pylint count low enough

@marc-hb marc-hb changed the title Simple dependency-free ALSA test rig for PCM capture analysis. [SKIP SOF-TEST] Simple dependency-free ALSA test rig for PCM capture analysis. Jan 3, 2024
marc-hb
marc-hb previously approved these changes Jan 3, 2024
Copy link
Collaborator

@marc-hb marc-hb left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Found a few more nits + easy fixes for 2 pylint warnings but Python codestyle looks great.

Need other people (@plbossart , @aiChaoSONG , @singalsu , @ranj063, others?) to review the audio logic and ALSA API usage.

tools/capture-test.py Outdated Show resolved Hide resolved
tools/capture-test.py Outdated Show resolved Hide resolved
heap. Provides a simple spot for putting (manually-derived)
constants. The ALSA C API is mostly-structless and quite simple, so
this tends to work well without a lot of ctypes use except for an
occasional constructed integer or byref() pointer.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Then what happens?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You have to write ctypes code directly, e.g. the one struct definition in the class here, or the fact that the return value of functions ending with "name" is forced to be a c_char_p (the default is a return value of "int", which breaks because it chops off the top bits of the pointer). Ctypes isn't super clean as a FFI, but it's not so bad. But 90% of ALSA code in this script looks just like ALSA code in C, with the advantage that the errno returns become exceptions.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
occasional constructed integer or byref() pointer.
occasional constructed integer or byref() pointer, e.g. pcm_channel_area_t below.

I read too fast and didn't realize this script has already sample solutions for this.

tools/capture-test.py Outdated Show resolved Hide resolved
if opts.chirp_test:
chirp_test()
if opts.echo_test:
echo_test()
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Kill four birds with one stone:

  • get rid of the pylint warning
  • make echo_test() more generic
  • make opts less global
  • do not add one level of indentation to the entire echo_test() code
Suggested change
echo_test()
with open(opts.noise, "rb").read()[HEADER_LEN:] as buf:
echo_test(buf)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That looks clever, but interestingly this doesn't work. It turns out that the "with" syntax isn't integrated with the internal reference counter at all[1], it's done at the class level by forcing the value assigned to implement __enter__/__exit__ methods. A file object has that, but a bytes object doesn't (because it's a heap block handled by the interpreter refcount!). So the result is "AttributeError: __enter__"

Really that "with" warning is a false positive. What it's detecting is the call to open(). But the object gets used and discarded in just the one line of code ("with" requires enter/exit, but the actual file descriptor gets closed out of the refcounter), so there's nothing to clean up. To get this right, pylint would need to do lifetime analysis of the result of open() and not just detect that it's called, and it's not that smart.

[1] Which, sigh, is very much on brand for python. All about top-level API definitions and never about clean integration with a core interpreter engine.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess the following could have worked (the previous suggestion was hasty copy/paste) but I understand it is a false positive in the first place.

with open(opts.noise, "rb") as reader:
    echo_test(reader)

Just drop this script on a test device to run it.  No tools to build,
no dependencies to install.  Confirmed to run on Python 3.8+ with
nothing more than the core libraries and a working libasound.so.2
visible to the runtime linker.

When run without arguments, the tool will record from the capture
device for the specified duration, then emit the resulting samples
back out the playback device without processing (except potentially to
convert the sample format from s32_le to s16_le if needed, and to
discard any channels beyond those supported by the playback device).

Passing --chirp-test enables a playback-to-capture latency detector:
the tool will emit a short ~6 kHz wave packet via ALSA's mmap
interface (which allows measuring and correcting for the buffer
latency from the userspace process) and simultaneously loop on short
reads from the capture device looking for the moment it arrives.

Passing --echo-test enables a capture-while-playback test.  The script
will play a specified .wav file ("noise.wav" by default) for the
specified duration, while simultaneously capturing, and report the
"power" (in essentially arbitrary units, but it's linear with actual
signal energy assuming the sample space is itself linear) of the
captured data to stdout at the end of the test.

Signed-off-by: Andy Ross <[email protected]>
Copy link
Collaborator

@marc-hb marc-hb left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as before: code style looks good, someone else must approve the audio and ALSA API parts

if opts.chirp_test:
chirp_test()
if opts.echo_test:
echo_test()
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess the following could have worked (the previous suggestion was hasty copy/paste) but I understand it is a false positive in the first place.

with open(opts.noise, "rb") as reader:
    echo_test(reader)

Copy link
Contributor

@kv2019i kv2019i left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm good with this. In majority of cases, we'll be using aplay/arecord et al, so I don't think this has a be a perfect copy. But I do think this can be a very valueble tool on constrained environments and I can see value in having this available in sof-test.

@marc-hb
Copy link
Collaborator

marc-hb commented Jan 8, 2024

@plbossart any objection?

@marc-hb marc-hb merged commit bc3ced5 into thesofproject:main Jan 10, 2024
2 of 3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants