blob: e6981391c68d225fe48703ee45fc9dfb35b06bbc [file] [log] [blame]
// Copyright 2020 The Chromium OS Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
// Package crastestclient provides functions to interact cras_test_client
package crastestclient
import (
"context"
"regexp"
"strconv"
"strings"
"time"
"chromiumos/tast/common/testexec"
"chromiumos/tast/errors"
"chromiumos/tast/local/audio"
"chromiumos/tast/testing"
)
type cmdMode int
const (
captureMode cmdMode = iota
playbackMode
)
// blockSize calculates default block size from rate. This should be aligned as defined in cras_test_client.
func blockSize(rate int) int {
const playbackBufferedTimeUs = 5000
return rate * playbackBufferedTimeUs / 1000000
}
// crasTestClientCommand creates a cras_test_client command.
func crasTestClientCommand(ctx context.Context, mode cmdMode, file string, duration, channels, blocksize, rate int) *testexec.Cmd {
runStr := "--playback_file"
if mode == captureMode {
runStr = "--capture_file"
}
return testexec.CommandContext(
ctx, "cras_test_client",
runStr, file,
"--duration", strconv.Itoa(duration),
"--num_channels", strconv.Itoa(channels),
"--block_size", strconv.Itoa(blocksize),
"--rate", strconv.Itoa(rate))
}
// PlaybackFileCommand creates a cras_test_client playback-from-file command.
func PlaybackFileCommand(ctx context.Context, file string, duration, channels, rate int) *testexec.Cmd {
return crasTestClientCommand(ctx, playbackMode, file, duration, channels, blockSize(rate), rate)
}
// PlaybackCommand creates a cras_test_client playback command.
func PlaybackCommand(ctx context.Context, duration, blocksize int) *testexec.Cmd {
return crasTestClientCommand(ctx, playbackMode, "/dev/zero", duration, 2, blocksize, 48000)
}
// CaptureFileCommand creates a cras_test_client capture-to-file command.
func CaptureFileCommand(ctx context.Context, file string, duration, channels, rate int) *testexec.Cmd {
return crasTestClientCommand(ctx, captureMode, file, duration, channels, blockSize(rate), rate)
}
// CaptureCommand creates a cras_test_client capture command.
func CaptureCommand(ctx context.Context, duration, blocksize int) *testexec.Cmd {
return crasTestClientCommand(ctx, captureMode, "/dev/null", duration, 2, blocksize, 48000)
}
// FirstRunningDevice returns the first input/output device by parsing audio thread logs.
// A device may not be opened immediately so it will repeat a query until there is a running device or timeout.
func FirstRunningDevice(ctx context.Context, streamType audio.StreamType) (string, error) {
var re *regexp.Regexp
if streamType == audio.InputStream {
re = regexp.MustCompile("Input dev: (.*)")
} else if streamType == audio.OutputStream {
re = regexp.MustCompile("Output dev: (.*)")
} else {
return "", errors.Errorf("unsupported StreamType %d", streamType)
}
var devName string
if err := testing.Poll(ctx, func(ctx context.Context) error {
testing.ContextLog(ctx, "Dump audio thread to check running devices")
dump, err := testexec.CommandContext(ctx, "cras_test_client", "--dump_audio_thread").Output()
if err != nil {
return testing.PollBreak(errors.Errorf("failed to dump audio thread: %s", err))
}
dev := re.FindStringSubmatch(string(dump))
if dev == nil {
return errors.New("no such device")
}
devName = dev[1]
return nil
}, &testing.PollOptions{Timeout: 3 * time.Second}); err != nil {
return "", err
}
return devName, nil
}
// StreamInfo holds attributes of an active stream.
// It contains only test needed fields.
type StreamInfo struct {
Direction string
Effects uint64
FrameRate uint32
NumChannels uint8
IsPinned bool
ClientType string
}
var streamInfoRegex = regexp.MustCompile("(.*):(.*)")
func newStreamInfo(s string) (*StreamInfo, error) {
data := streamInfoRegex.FindAllStringSubmatch(s, -1)
res := make(map[string]string)
for _, kv := range data {
k := kv[1]
v := strings.Trim(kv[2], " ")
res[k] = v
}
const (
Direction = "direction"
Effects = "effects"
FrameRate = "frame_rate"
NumChannels = "num_channels"
isPinned = "is_pinned"
ClientType = "client_type"
)
// Checks all key exists.
for _, k := range []string{Direction, Effects, FrameRate, NumChannels, isPinned, ClientType} {
if _, ok := res[k]; !ok {
return nil, errors.Errorf("missing key: %s in StreamInfo", k)
}
}
effects, err := strconv.ParseUint(res[Effects], 0, 64)
if err != nil {
return nil, errors.Wrapf(err, "failed to parse StreamInfo::%s (value: %s)", Effects, res[Effects])
}
frameRate, err := strconv.ParseUint(res[FrameRate], 10, 32)
if err != nil {
return nil, errors.Wrapf(err, "failed to parse StreamInfo::%s (value: %s)", FrameRate, res[FrameRate])
}
numChannels, err := strconv.ParseUint(res[NumChannels], 10, 8)
if err != nil {
return nil, errors.Wrapf(err, "failed to parse StreamInfo::%s (value: %s)", NumChannels, res[NumChannels])
}
pinned, err := strconv.ParseUint(res[isPinned], 10, 32)
if err != nil {
return nil, errors.Wrapf(err, "failed to parse StreamInfo::%s (value: %s)", isPinned, res[isPinned])
}
return &StreamInfo{
Direction: res[Direction],
Effects: effects,
FrameRate: uint32(frameRate),
NumChannels: uint8(numChannels),
IsPinned: pinned != 0,
ClientType: res[ClientType],
}, nil
}
// PollStreamResult is the CRAS stream polling result.
type PollStreamResult struct {
Streams []StreamInfo
Error error
}
// StartPollStreamWorker starts a goroutine to poll an active stream.
func StartPollStreamWorker(ctx context.Context, timeout time.Duration) <-chan PollStreamResult {
resCh := make(chan PollStreamResult, 1)
go func() {
streams, err := WaitForStreams(ctx, timeout)
resCh <- PollStreamResult{Streams: streams, Error: err}
}()
return resCh
}
// WaitForStreams returns error if it fails to detect any active streams.
func WaitForStreams(ctx context.Context, timeout time.Duration) ([]StreamInfo, error) {
var streams []StreamInfo
err := testing.Poll(ctx, func(ctx context.Context) error {
testing.ContextLog(ctx, "Polling active stream")
var err error
streams, err = dumpActiveStreams(ctx)
if err != nil {
return testing.PollBreak(errors.Errorf("failed to parse audio dumps: %s", err))
}
if len(streams) == 0 {
return &NoStreamError{E: errors.New("no stream detected")}
}
// There is some active streams.
return nil
}, &testing.PollOptions{Timeout: timeout})
return streams, err
}
// WaitForNoStream returns error if it fails to wait for all active streams to stop.
func WaitForNoStream(ctx context.Context, timeout time.Duration) error {
return testing.Poll(ctx, func(ctx context.Context) error {
testing.ContextLog(ctx, "Wait until there is no active stream")
streams, err := dumpActiveStreams(ctx)
if err != nil {
return testing.PollBreak(errors.Errorf("failed to parse audio dumps: %s", err))
}
if len(streams) > 0 {
return errors.New("active stream detected")
}
// No active stream.
return nil
}, &testing.PollOptions{Timeout: timeout})
}
// NoStreamError represents the error of failing to detect any active streams.
type NoStreamError struct {
*errors.E
}
// dumpActiveStreams parses active streams from "cras_test_client --dump_audio_thread" log.
// The log format is defined in cras_test_client.c.
// The active streams section begins with: "-------------stream_dump------------" and ends with: "Audio Thread Event Log:"
// Each stream is separated by "\n\n"
// An example of "cras_test_client --dump_audio_thread" log is shown as below:
// -------------stream_dump------------
// stream: 94437376 dev: 6
// direction: Output
// stream_type: CRAS_STREAM_TYPE_DEFAULT
// client_type: CRAS_CLIENT_TYPE_PCM
// buffer_frames: 2000
// cb_threshold: 1000
// effects: 0x0000
// frame_rate: 8000
// num_channels: 1
// longest_fetch_sec: 0.004927402
// num_overruns: 0
// is_pinned: 0
// pinned_dev_idx: 0
// num_missed_cb: 0
// volume: 1.000000
// runtime: 26.168175600
// channel map:0 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1
//
// stream: 94437379 dev: 2
// ...
//
// Audio Thread Event Log:
//
func dumpActiveStreams(ctx context.Context) ([]StreamInfo, error) {
dump, err := testexec.CommandContext(ctx, "cras_test_client", "--dump_audio_thread").Output(testexec.DumpLogOnError)
if err != nil {
return nil, errors.Errorf("failed to dump audio thread: %s", err)
}
streamSection := strings.Split(string(dump), "-------------stream_dump------------")
if len(streamSection) != 2 {
return nil, errors.New("failed to split log by stream_dump")
}
streamSection = strings.Split(streamSection[1], "Audio Thread Event Log:")
if len(streamSection) == 1 {
return nil, errors.New("invalid stream_dump")
}
str := strings.Trim(streamSection[0], " \n\t")
// No active streams, return nil
if str == "" {
return nil, nil
}
var streams []StreamInfo
for _, streamStr := range strings.Split(str, "\n\n") {
stream, err := newStreamInfo(streamStr)
if err != nil {
return nil, errors.Wrap(err, "failed to parse stream")
}
streams = append(streams, *stream)
}
return streams, nil
}