Powered by AppSignal & Oban Pro

Audio

priv/samples/10_audio.livemd

Audio

Overview

The phone has three audio parts:

  • the loudspeaker, a swappable module with its own amplifier,
  • the earpiece, the small speaker you hold to your ear during a call,
  • the microphone.

The earpiece and the microphone go through the WCD9335 audio codec. All three are driven through the phone's audio DSP. Sound is played on plughw:0,0 and recorded from hw:0,1; ALSA mixer controls decide whether playback goes to the loudspeaker or the earpiece.

Switch between them while nothing is playing: changing the route in the middle of a playback stops it with an input/output error. To move a sound that is playing, stop it, switch, and carry on from where it was; Switch while playing shows how.

The loudspeaker module is what differs between the Fairphone 3 and the 3+, and since it can be swapped, a phone sold as one may carry the other's:

Loudspeaker module Amplifier Sound card name
Fairphone 3 Awinic AW8898 Fairphone 3
Fairphone 3+ TI TAS2557 Fairphone 3+

Only the TAS2557 has a Speaker Switch control. This cell reads the sound card name to know whether to set it. ExAudio sets the mixer controls; set applies a list of them and shows the result of each one:

tas2557? = File.read!("/proc/asound/cards") =~ "Fairphone 3+"

speaker_switch = fn value -> if tas2557?, do: [{"Speaker Switch", value}], else: [] end

set = fn controls ->
  for {name, value} <- controls, do: {name, ExAudio.cset(name, value)}
end

IO.puts("Loudspeaker amplifier: #{if tas2557?, do: "TAS2557", else: "AW8898"}")

Loudspeaker

Route audio to the loudspeaker

The firmware enables this route at boot. Run it again to come back from the earpiece.

speaker =
  [
    {"Earpiece Switch", :off},
    {"SLIMBUS_0_RX Audio Mixer MultiMedia1", :off},
    {"QUIN_MI2S_RX Audio Mixer MultiMedia1", :on}
  ] ++ speaker_switch.(:on)

set.(speaker)

Play a tone

Generate one second of a 440 Hz tone, save it as a WAV file and play it with aplay.

rate = 48_000
volume = 0.1

tone = for n <- 0..(rate - 1), do: volume * :math.sin(2 * :math.pi() * 440 * n / rate)

NervesAI.Audio.WAV.write("/tmp/tone.wav", tone, rate: rate)
System.cmd("aplay", ["-D", "plughw:0,0", "/tmp/tone.wav"], stderr_to_stdout: true)

Change volume (0.0 to 1.0) and run it again.

A short melody

notes = [262, 294, 330, 349, 392, 440, 494, 523]

melody =
  for freq <- notes, n <- 0..(div(rate, 4) - 1) do
    volume * :math.sin(2 * :math.pi() * freq * n / rate)
  end

NervesAI.Audio.WAV.write("/tmp/melody.wav", melody, rate: rate)
System.cmd("aplay", ["-D", "plughw:0,0", "/tmp/melody.wav"], stderr_to_stdout: true)

Earpiece

Route audio to the earpiece

This turns the loudspeaker off and sends the same playback device to the earpiece instead.

earpiece =
  speaker_switch.(:off) ++
    [
      {"QUIN_MI2S_RX Audio Mixer MultiMedia1", :off},
      {"SLIMBUS_0_RX Audio Mixer MultiMedia1", :on},
      {"SLIM RX0 MUX", "AIF_MIX1_PB"},
      {"RX INT0_1 MIX1 INP0", "RX0"},
      {"RX INT0 DEM MUX", "CLSH_DSM_OUT"},
      {"RX0 Digital Volume", 84},
      {"EAR PA Volume", 4},
      {"Earpiece Switch", :on}
    ]

set.(earpiece)

Listen

The earpiece is quiet: hold the top of the phone to your ear, then run this to play the melody again.

System.cmd("aplay", ["-D", "plughw:0,0", "/tmp/melody.wav"], stderr_to_stdout: true)

Run the loudspeaker route above to switch back.

Switch while playing

A call app moves the sound between the earpiece and the loudspeaker while it plays. The route can only change while nothing plays, so this player feeds the sound to aplay a tenth of a second at a time. Switching stops aplay, waits until the sound card has closed the stream, applies the new route and starts a new aplay with the rest of the sound. There is a short gap at the switch.

defmodule SwitchablePlayer do
  @chunk_ms 100
  @prefill 3

  # Plays mono samples (floats between -1.0 and 1.0) at `rate` and
  # returns the player.
  def start(samples, rate) do
    pcm = for sample <- samples, into: <<>>, do: <<round(sample * 32_767)::little-signed-16>>
    spawn(fn -> play(pcm, rate) end)
  end

  # Moves the sound to another route: `route` is called while nothing plays.
  def switch(player, route), do: send(player, {:switch, route})

  defp play(pcm, rate) do
    port = open(rate)
    pcm = feed(port, pcm, rate, @prefill)
    loop(port, pcm, rate, System.monotonic_time(:millisecond), 0)
  end

  defp loop(port, <<>>, _rate, _t0, _n), do: send(port, {self(), :close})

  defp loop(port, pcm, rate, t0, n) do
    receive do
      {:switch, route} ->
        stop(port)
        route.()
        play(pcm, rate)
    after
      max(t0 + n * @chunk_ms - System.monotonic_time(:millisecond), 0) ->
        loop(port, feed(port, pcm, rate, 1), rate, t0, n + 1)
    end
  end

  defp feed(port, pcm, rate, chunks) do
    size = min(byte_size(pcm), div(rate * 2 * @chunk_ms, 1000) * chunks)
    <<chunk::binary-size(size), rest::binary>> = pcm
    Port.command(port, chunk)
    rest
  end

  defp open(rate) do
    aplay = System.find_executable("aplay")
    args = ~w(-q -D plughw:0,0 -t raw -f S16_LE -c 1 -r #{rate} -)
    Port.open({:spawn_executable, aplay}, [:binary, :exit_status, args: args])
  end

  # SIGKILL: on SIGTERM, an aplay waiting for more sound keeps waiting.
  defp stop(port) do
    {:os_pid, pid} = Port.info(port, :os_pid)
    System.cmd("kill", ["-KILL", Integer.to_string(pid)])

    receive do
      {^port, {:exit_status, _}} -> :ok
    after
      2_000 -> :ok
    end

    wait_closed(30)
  end

  # The card shuts the old route down after the stream closes, and the
  # device is busy until then.
  defp wait_closed(0), do: :timeout

  defp wait_closed(tries) do
    case File.read("/proc/asound/card0/pcm0p/sub0/status") do
      {:ok, "closed" <> _} -> :ok
      _ -> Process.sleep(100) && wait_closed(tries - 1)
    end
  end
end

Play the melody four times on the loudspeaker:

set.(speaker)
player = SwitchablePlayer.start(List.flatten(List.duplicate(melody, 4)), rate)

While it plays, move it to the earpiece:

SwitchablePlayer.switch(player, fn -> set.(earpiece) end)

And back to the loudspeaker:

SwitchablePlayer.switch(player, fn -> set.(speaker) end)

Microphone

Route the microphone

This connects the phone's main microphone to the recording device.

microphone = [
  {"MultiMedia2 Mixer SLIMBUS_0_TX", :on},
  {"SLIM TX7 MUX", "DEC7"},
  {"ADC MUX7", "DMIC"},
  {"DMIC MUX7", "DMIC1"},
  {"AIF1_CAP Mixer SLIM TX7", :on}
]

set.(microphone)

Which microphone?

The phone has two microphones, both digital microphones on the codec, and they sit at opposite ends:

Input Where What it is for
DMIC1 bottom, next to the loudspeaker the main microphone: voice and recordings
DMIC2 top, next to the earpiece the second microphone, used to pick up background noise

It is the same on the Fairphone 3 and the 3+. The route above selects DMIC1. The codec's other inputs (DMIC0, DMIC3 to DMIC5 and the analog ones) have nothing connected and record silence or noise.

DMIC MUX7 picks the microphone. This shows which one is selected:

dmics = ~w(ZERO DMIC0 DMIC1 DMIC2 DMIC3 DMIC4 DMIC5)
{:ok, index} = ExAudio.cget("DMIC MUX7")
IO.puts("Recording from #{Enum.at(dmics, String.to_integer(index))}")

And this switches. Pick "DMIC2", run the recording below, then compare it with one made on "DMIC1": holding the top or the bottom of the phone close to your mouth makes the difference obvious.

use_microphone = fn mic -> set.([{"DMIC MUX7", mic}]) end

use_microphone.("DMIC2")

Record

Records 5 seconds. Talk, clap or whistle while it runs.

arecord = fn seconds, path ->
  System.cmd("arecord", ~w(-D hw:0,1 -f S16_LE -r 48000 -c 1 -d #{seconds} #{path}),
    stderr_to_stdout: true
  )
end

# The first capture after boot returns silence; a short one wakes the path up.
arecord.(1, "/tmp/warmup.wav")
IO.puts("Recording for 5 seconds...")
arecord.(5, "/tmp/me.wav")

How loud was it?

The recording is split into tenths of a second, and each bar shows the loudest sample in that slice.

{:ok, %{rate: rate, pcm_f32: pcm}} = NervesAI.Audio.WAV.read("/tmp/me.wav")

for(<<sample::float-32-little <- pcm>>, do: abs(sample))
|> Enum.chunk_every(div(rate, 10))
|> Enum.map(fn slice ->
  peak = Enum.max(slice)
  String.duplicate("█", round(peak * 50)) <> " #{Float.round(peak, 2)}"
end)
|> Enum.join("\n")
|> IO.puts()

A silent room stays near 0; speaking close to the phone reaches 0.3 or more.

Play it back

Plays the recording on the loudspeaker:

set.(speaker)
System.cmd("aplay", ["-D", "plughw:0,0", "/tmp/me.wav"], stderr_to_stdout: true)

Or in the earpiece:

set.(earpiece)
System.cmd("aplay", ["-D", "plughw:0,0", "/tmp/me.wav"], stderr_to_stdout: true)

Play your own file

aplay plays WAV files. Copy one to the phone (for example with sftp nerves.local) and play it:

System.cmd("aplay", ["-D", "plughw:0,0", "/data/my_sound.wav"], stderr_to_stdout: true)