Skip to content

Commit

Permalink
Implement gui and tui for sof demonstration
Browse files Browse the repository at this point in the history
Implements a gui and tui that can be used to easily demonstrate SOF
on target HW. See README and README-dev for more information on
functionality and purpose.

Signed-off-by: Alexander Brown <[email protected]>
  • Loading branch information
alexb3103 authored and lgirdwood committed Oct 1, 2024
1 parent e5a2746 commit 23d3d55
Show file tree
Hide file tree
Showing 8 changed files with 932 additions and 0 deletions.
40 changes: 40 additions & 0 deletions tools/demo-gui/README-dev.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
## Developing sof-gui and tui

The architecture of the SOF UIs is simple, and designed to make implementing new features extremely easy.

Controller engine "sof_controller_engine.py", that handles all interaction with Linux and SOF.

GUI "sof_demo_gui.py", links a GTK gui with the controller engine.

TUI "sof_demo_tui.py", links a text UI to the controller engine.

eq_configs and audios folders to contain example audios and EQ commands.

Pipeline within topology folder, named:
```sof-<hardware-name>-gui-components-wm8960.tplg```

## Adding a new component to the UIs

There are three main things that need to be edited to add a new component:

### Controller engine

Provide required sof_ctl calls and other necessary logic to control the component. Update execute_command to contain the desired commands. Ensure that autodetection is used for commands so that the implementation is generic.

### GUI and TUI

Add new buttons to the init method or gui that provide the needed functionality for the component. These should be designed to call methods in controller engine that will then interact with SOF and Linux

### Pipeline

See relevant documentation for pipeline development. Ensure any control needed is exposed through the pipeline. Also ensure the pipeline is set to build for your target HW within the cmakefiles.

## Next steps for overall UI development

Add DRC and other base level SOF components.

Add real time EQ config generation, so the user could control low, mid, and high controls in real time. This would require a new EQ component that supports smooth real time control.

Add graphics and other quality of life functions to the GUI.

Create a version of sof-ctl that provides direct Python bindings to communicate with SOF components, rather than needing a Linux command.
37 changes: 37 additions & 0 deletions tools/demo-gui/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
## sof-demo-gui

### sof-demo-gui.py - sof-demo-tui.py
User input logic and display handling

### sof-controller-engine
Controller to abstract the GUI and TUI control.

Handles the linking of user input and sof_ctl generically of the control type.

### How to use the interfaces

Build sof-ctl for target and copy it to the gui folder base directory within your local repo.

If you have audio on your local machine that you wish to demonstrate using the GUI, add it to the audios subfolder.
Also, you can specify audio paths on the target with the command line arg --audio-path "path"

If you would like to include eq configs, they are stored in tools/ctl/ipc3. Copy them from there to the eq_configs folder.

Copy entire GUI folder to target hardware.

Next, ensure that the
```sof-<hardware-name>-gui-components-wm8960.tplg```
is built and loaded as SOF's topology. Make sure this is built for your target hardware.

After this, run either the GUI or TUI on a board with SOF loaded. This can be done using the command:
```python3 sof-demo-gui```
or
```python3 sof-demo-tui```

The interfaces themselves are self-explanatory, as they are made to be plug and play on all SOF supporting systems.

The features currently supported are:
Playback and Record with ALSA
Volume control using the SOF component
EQ component with realtime control
Generic implementation with autodetection of SOF cards and commands
197 changes: 197 additions & 0 deletions tools/demo-gui/demo-gui/sof_controller_engine.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
# SPDX-License-Identifier: BSD-3-Clause

import subprocess
import os
import signal
import re
import math

# Global variables to store the aplay/arecord process, paused state, current file, detected device, volume control, and EQ numid
aplay_process = None
arecord_process = None
paused = None
current_file = None
device_string = None
volume_control = None
eq_numid = None

extra_audio_paths = []

def initialize_device():
global device_string, volume_control, eq_numid

try:
output = subprocess.check_output(["aplay", "-l"], text=True, stderr=subprocess.DEVNULL)

match = re.search(r"card (\d+):.*\[.*sof.*\]", output, re.IGNORECASE)
if match:
card_number = match.group(1)
device_string = f"hw:{card_number}"
print(f"Detected SOF card: {device_string}")
else:
print("No SOF card found.")
raise RuntimeError("SOF card not found. Ensure the device is connected and recognized by the system.")

controls_output = subprocess.check_output(["amixer", f"-D{device_string}", "controls"], text=True, stderr=subprocess.DEVNULL)

volume_match = re.search(r"numid=(\d+),iface=MIXER,name='(.*Master Playback Volume.*)'", controls_output)
if volume_match:
volume_control = volume_match.group(2)
print(f"Detected Volume Control: {volume_control}")
else:
print("Master GUI Playback Volume control not found.")
raise RuntimeError("Volume control not found.")

eq_match = re.search(r"numid=(\d+),iface=MIXER,name='EQIIR1\.0 eqiir_coef_1'", controls_output)
if eq_match:
eq_numid = eq_match.group(1)
print(f"Detected EQ numid: {eq_numid}")
else:
print("EQ control not found.")
raise RuntimeError("EQ control not found.")

except subprocess.CalledProcessError as e:
print(f"Failed to run device detection commands: {e}")
raise

def scale_volume(user_volume):
normalized_volume = user_volume / 100.0
scaled_volume = 31 * (math.sqrt(normalized_volume))
return int(round(scaled_volume))

def scan_for_files(directory_name: str, file_extension: str, extra_paths: list = None):
found_files = []
dir_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), directory_name)

if os.path.exists(dir_path):
found_files.extend([f for f in os.listdir(dir_path) if f.endswith(file_extension)])
else:
print(f"Error: The '{directory_name}' directory is missing. It should be located in the same folder as this script.")

if extra_paths:
for path in extra_paths:
if os.path.exists(path):
found_files.extend([f for f in os.listdir(path) if f.endswith(file_extension)])
else:
print(f"Warning: The directory '{path}' does not exist.")

return found_files

def execute_command(command: str, data: int = 0, file_name: str = None):
if device_string is None or volume_control is None or eq_numid is None:
raise RuntimeError("Device not initialized. Call initialize_device() first.")

command_switch = {
'volume': lambda x: handle_volume(data),
'eq': lambda x: handle_eq(file_name),
'play': lambda x: handle_play(file_name),
'pause': lambda x: handle_pause(),
'record': lambda x: handle_record(start=data, filename=file_name)
}

command_function = command_switch.get(command, lambda x: handle_unknown_command(data))
command_function(data)

def handle_volume(data: int):
amixer_command = f"amixer -D{device_string} cset name='{volume_control}' {data}"
try:
subprocess.run(amixer_command, shell=True, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
except subprocess.CalledProcessError as e:
print(f"Failed to set volume: {e}")

def handle_eq(eq_file_name: str):
ctl_command = f"./sof-ctl -D{device_string} -n {eq_numid} -s {eq_file_name}"
try:
subprocess.run(ctl_command, shell=True, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
except subprocess.CalledProcessError as e:
print(f"Failed to apply EQ settings: {e}")

def handle_play(play_file_name: str):
global aplay_process, paused, current_file

if paused and paused is not None and current_file == play_file_name:
os.kill(aplay_process.pid, signal.SIGCONT)
print("Playback resumed.")
paused = False
return

if aplay_process is not None:
if aplay_process.poll() is None:
if current_file == play_file_name:
print("Playback is already in progress.")
return
else:
os.kill(aplay_process.pid, signal.SIGKILL)
print("Stopping current playback to play a new file.")
else:
print("Previous process is not running, starting new playback.")

default_audio_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'audios')
file_path = next((os.path.join(path, play_file_name) for path in [default_audio_dir] + extra_audio_paths if os.path.exists(os.path.join(path, play_file_name))), None)

if file_path is None:
print(f"Error: File '{play_file_name}' not found in the default 'audios' directory or any provided paths.")
return

aplay_command = f"aplay -D{device_string} '{file_path}'"

try:
aplay_process = subprocess.Popen(aplay_command, shell=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
current_file = play_file_name
print(f"Playing file: {play_file_name}.")
paused = False
except subprocess.CalledProcessError as e:
print(f"Failed to play file: {e}")

def handle_pause():
global aplay_process, paused

if aplay_process is None:
print("No playback process to pause.")
return

if aplay_process.poll() is not None:
print("Playback process has already finished.")
return

try:
os.kill(aplay_process.pid, signal.SIGSTOP)
paused = True
print("Playback paused.")
except Exception as e:
print(f"Failed to pause playback: {e}")

def handle_record(start: bool, filename: str):
global arecord_process

if start:
if arecord_process is not None and arecord_process.poll() is None:
print("Recording is already in progress.")
return

if not filename:
print("No filename provided for recording.")
return

record_command = f"arecord -D{device_string} -f cd -t wav {filename}"

try:
arecord_process = subprocess.Popen(record_command, shell=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
print(f"Started recording: {filename}")
except subprocess.CalledProcessError as e:
print(f"Failed to start recording: {e}")

else:
if arecord_process is None or arecord_process.poll() is not None:
print("No recording process to stop.")
return

try:
os.kill(arecord_process.pid, signal.SIGINT)
arecord_process = None
print(f"Stopped recording.")
except Exception as e:
print(f"Failed to stop recording: {e}")

def handle_unknown_command(data: int):
print(f"Unknown command: {data}")
Loading

0 comments on commit 23d3d55

Please sign in to comment.