Initial commit
|
@ -0,0 +1,32 @@
|
||||||
|
name: hifiscan
|
||||||
|
|
||||||
|
on: [ push, pull_request ]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
python-version: [ 3.7, 3.8, 3.9, "3.10" ]
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v2
|
||||||
|
|
||||||
|
- name: Set up Python ${{ matrix.python-version }}
|
||||||
|
uses: actions/setup-python@v2
|
||||||
|
with:
|
||||||
|
python-version: ${{ matrix.python-version }}
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
pip install flake8 mypy .
|
||||||
|
|
||||||
|
- name: Flake8 static code analysis
|
||||||
|
run:
|
||||||
|
flake8 hifiscan
|
||||||
|
|
||||||
|
- name: MyPy static code analysis
|
||||||
|
run: |
|
||||||
|
mypy -p hifiscan
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,12 @@
|
||||||
|
hifiscan/__pycache__
|
||||||
|
dist
|
||||||
|
build
|
||||||
|
.vscode
|
||||||
|
.idea
|
||||||
|
.settings
|
||||||
|
.spyproject
|
||||||
|
.project
|
||||||
|
.pydevproject
|
||||||
|
.mypy_cache
|
||||||
|
.eggs
|
||||||
|
hifiscan.egg-info
|
|
@ -0,0 +1,25 @@
|
||||||
|
BSD 2-Clause License
|
||||||
|
|
||||||
|
Copyright (c) 2022, Ewald de Wit
|
||||||
|
All rights reserved.
|
||||||
|
|
||||||
|
Redistribution and use in source and binary forms, with or without
|
||||||
|
modification, are permitted provided that the following conditions are met:
|
||||||
|
|
||||||
|
* Redistributions of source code must retain the above copyright notice, this
|
||||||
|
list of conditions and the following disclaimer.
|
||||||
|
|
||||||
|
* Redistributions in binary form must reproduce the above copyright notice,
|
||||||
|
this list of conditions and the following disclaimer in the documentation
|
||||||
|
and/or other materials provided with the distribution.
|
||||||
|
|
||||||
|
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||||
|
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||||
|
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||||
|
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
||||||
|
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
||||||
|
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
||||||
|
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
||||||
|
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
||||||
|
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||||
|
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
|
@ -0,0 +1,253 @@
|
||||||
|
|PyVersion| |Status| |PyPiVersion| |License|
|
||||||
|
|
||||||
|
Introduction
|
||||||
|
============
|
||||||
|
|
||||||
|
The goal of HiFiScan is to help equalize an audio system to get
|
||||||
|
the best possible audio quality from it.
|
||||||
|
There are two ways to do this:
|
||||||
|
|
||||||
|
1. Manual: The realtime frequency spectrum is displayed and
|
||||||
|
the peaks and troughs can be interactively equalized away.
|
||||||
|
|
||||||
|
2. Automatic: The frequency response is measured and a correction
|
||||||
|
is calculated. This correction is a phase-neutral finite impulse
|
||||||
|
response (FIR) that can be imported into most equalizer programs.
|
||||||
|
|
||||||
|
The measuring is done by playing a "chirp" sound that sweeps
|
||||||
|
across all frequencies and recording how loud each frequency comes out
|
||||||
|
of the speakers. A good microphone is needed, with a wide frequency range
|
||||||
|
and preferably with a flat frequency response.
|
||||||
|
|
||||||
|
The equalization itself is not provided; It can be performed by an
|
||||||
|
equalizer of your choice, such as
|
||||||
|
`EasyEffects <https://github.com/wwmm/easyeffects/>`_
|
||||||
|
for Linux,
|
||||||
|
`Equalizer APO <https://sourceforge.net/projects/equalizerapo/>`_
|
||||||
|
and
|
||||||
|
`Peace <https://sourceforge.net/projects/peace-equalizer-apo-extension/>`_
|
||||||
|
for Windows, or
|
||||||
|
`eqMac <https://eqmac.app/>`_ for macOS.
|
||||||
|
|
||||||
|
Installation
|
||||||
|
============
|
||||||
|
|
||||||
|
::
|
||||||
|
|
||||||
|
pip install hifiscan
|
||||||
|
|
||||||
|
The program is started from a console by typing::
|
||||||
|
|
||||||
|
hifiscan
|
||||||
|
|
||||||
|
All functionality is also available for interactive use in
|
||||||
|
`this Jupyter notebook <chirp.pynb>`_.
|
||||||
|
|
||||||
|
Examples
|
||||||
|
========
|
||||||
|
|
||||||
|
Laptop
|
||||||
|
------
|
||||||
|
|
||||||
|
Lets first optimize the speakers of a laptop.
|
||||||
|
The laptop has tiny down-firing speakers and a massive
|
||||||
|
case resonance that makes it sound about as bad as it gets.
|
||||||
|
|
||||||
|
The sound is recorded with a USB studio microphone; The built-in
|
||||||
|
microphone of the laptop is not suitable for this.
|
||||||
|
|
||||||
|
.. image:: images/laptop_setup.jpg
|
||||||
|
|
||||||
|
Letting the measurements run it becomes clear just how bad
|
||||||
|
the spectrum is, with a peak at 1 kHz about 20 dB above average.
|
||||||
|
Every 10 dB is a factor 10 in power, so 20 dB is a factor 100.
|
||||||
|
|
||||||
|
The low frequency is set to 200 Hz since the laptop can't possibly
|
||||||
|
output anything below this.
|
||||||
|
|
||||||
|
.. image:: images/laptop-spectrum.png
|
||||||
|
|
||||||
|
To get an automatic correction, we go to the "Impulse Response" section
|
||||||
|
(selectable in the lower left corner). From here it's possible to use
|
||||||
|
all-default values and click straight on "Export as WAV" to get a
|
||||||
|
perfectly adequate result.
|
||||||
|
|
||||||
|
But lets optimize a bit further for this laptop. There are various
|
||||||
|
tradeoffs that can be made, one of which involves the **Duration**
|
||||||
|
of the impulse. A longer duration gives better bass control,
|
||||||
|
but also adds more latency.
|
||||||
|
The latency added by the equalizer is halve the duration of the impulse.
|
||||||
|
Since the laptop has no bass anyway, we choose a 22 ms duration for a
|
||||||
|
super-low 11 ms latency. This is less time than it takes sound to travel
|
||||||
|
four meters and is good enough even for gaming or video-calls.
|
||||||
|
|
||||||
|
We also increase the **Range** to 27 dB to get just a little bit of
|
||||||
|
extra equalization.
|
||||||
|
|
||||||
|
The lower graph (in brown) shows how the equalized spectrum is expected
|
||||||
|
to be, and it looks nicely flattened.
|
||||||
|
|
||||||
|
.. image:: images/laptop-IR.png
|
||||||
|
|
||||||
|
So lets export the impulse response and import
|
||||||
|
it into EasyEffects (In Convolver effect: "Impulses -> Import Impulse"
|
||||||
|
and then "Load"):
|
||||||
|
|
||||||
|
.. image:: images/Convolver.png
|
||||||
|
|
||||||
|
We go back to the spectrum measurement and set the uncorrected
|
||||||
|
spectrum as reference (to compare with later measurements).
|
||||||
|
Measuring the equalized system gives this:
|
||||||
|
|
||||||
|
.. image:: images/laptop-flattened-spectrum.png
|
||||||
|
|
||||||
|
It is seen that the equalization works by attenuation only:
|
||||||
|
Everything gets chopped to some level under the top (27 dB here)
|
||||||
|
and this flattens the whole landscape.
|
||||||
|
|
||||||
|
All this attenuation does decrease the total loudness, so the
|
||||||
|
volume has to be turned up to get the same loudness. This also
|
||||||
|
brings up the flanks of the spectrum and increases the effective
|
||||||
|
frequency range. There's a very welcome 40 Hz of extra bass and
|
||||||
|
a whole lot of treble:
|
||||||
|
|
||||||
|
.. image:: images/laptop-spectrum-equivolume.png
|
||||||
|
|
||||||
|
This is the point to leave the graphs and start to listen to
|
||||||
|
some music. Is there an improvement? There are of course lots
|
||||||
|
of different tastes in what sounds good, but for those who like
|
||||||
|
a neutrally balanced sound there is a huge improvement. Voices
|
||||||
|
are also much easier to understand.
|
||||||
|
|
||||||
|
The lack of bass is somewhat offset by the
|
||||||
|
`missing fundamental <https://en.wikipedia.org/wiki/Missing_fundamental>`_
|
||||||
|
phenomenon, were the brain "adds" a missing low frequency based on
|
||||||
|
its higher frequency harmonics. It seems that by equalizing the
|
||||||
|
harmonics the phantom bass gets equalized as well.
|
||||||
|
|
||||||
|
HiFi Stereo
|
||||||
|
-----------
|
||||||
|
|
||||||
|
The HiFi installation has four JBL surround loudspeakers wired
|
||||||
|
in series as a 2x2 stereo setup, plus a subwoofer. The sound
|
||||||
|
can only be described as very dull, as if the tweeters are
|
||||||
|
not working.
|
||||||
|
|
||||||
|
To calibrate we use the same microphone as for the laptop,
|
||||||
|
which is a Superlux E205UMKII.
|
||||||
|
Lets this time correct for any non-flatness of the microphone.
|
||||||
|
According to the documentation
|
||||||
|
it has this frequency response:
|
||||||
|
|
||||||
|
.. image:: images/mic_response.png
|
||||||
|
|
||||||
|
With EasyEffects we make the following correction.
|
||||||
|
The correction can be applied either to the input or the
|
||||||
|
output. Here it's applied to the output, as long as it is
|
||||||
|
turned off after the calibration that's OK.
|
||||||
|
|
||||||
|
.. image:: images/mic_correction.png
|
||||||
|
|
||||||
|
Measuring the spectrum bears out the concerning lack
|
||||||
|
of treble:
|
||||||
|
|
||||||
|
.. image:: images/stereo-spectrum.png
|
||||||
|
|
||||||
|
So lets go to the Impulse Response section to fix this.
|
||||||
|
|
||||||
|
The **Range** is set to 33 dB - this is an extreme value but what the heck.
|
||||||
|
|
||||||
|
The **Tapering** is left at 5. It pulls the flanks of the Impulse
|
||||||
|
Response closer to zero (visible in the green curve), which also has
|
||||||
|
a smoothing effect on the spectrum. A value less than 5 might leave
|
||||||
|
the flanks of the green curve too high and this can cause nasty
|
||||||
|
`pre-echos <https://en.wikipedia.org/wiki/Pre-echo>`_.
|
||||||
|
A value higher than 5 might cause too much smoothing of the bass
|
||||||
|
region.
|
||||||
|
|
||||||
|
The **Smoothing** will also smooth the spectrum, but the smoothing is
|
||||||
|
done proportional to the frequency. It will smooth the bass region
|
||||||
|
less, allowing for better precision there. A good smoothing value
|
||||||
|
can be judged from the Correction Factor graph (in red): It should
|
||||||
|
be smooth with nicely rounded corners, yet with enough detail.
|
||||||
|
|
||||||
|
The **Duration** is fiddled with until an acceptable bass response is
|
||||||
|
reached (visible in lowest graph in brown).
|
||||||
|
|
||||||
|
.. image:: images/stereo-ir.png
|
||||||
|
|
||||||
|
After exporting the Impulse Response and importing it into
|
||||||
|
EasyEffects the result looks promising.
|
||||||
|
|
||||||
|
.. image:: images/stereo-spectrum-corrected.png
|
||||||
|
|
||||||
|
We turn up the volume to get the same loudness as before and
|
||||||
|
apply some visual smoothing to the spectrum for clarity.
|
||||||
|
It turns out that the tweeters can
|
||||||
|
do their job if only the amplifier drives them 100 times as hard.
|
||||||
|
|
||||||
|
.. image:: images/stereo-final.png
|
||||||
|
|
||||||
|
The difference in sound quality is night and day. Music is really
|
||||||
|
really good now. For movies it brings very immersive
|
||||||
|
action and excellent clarity of dialogue.
|
||||||
|
|
||||||
|
As mentioned in the introduction, the equalization is phase-
|
||||||
|
neutral. This means that despite the heavy and steep equalization
|
||||||
|
there are no relative phase shifts added. The details in a
|
||||||
|
lossless source of music (such as the bounces of a cymbal)
|
||||||
|
remain as crisp as can be.
|
||||||
|
|
||||||
|
As an aside, the amplifier used is a $18 circuit board based on the
|
||||||
|
`TPA3116D2 digital amplifier chip <https://www.ti.com/product/TPA3116D2>`_.
|
||||||
|
It draws 1.1 Watt while playing which only increases if the subwoofer
|
||||||
|
is really busy.
|
||||||
|
|
||||||
|
Bluetooth headphones
|
||||||
|
--------------------
|
||||||
|
|
||||||
|
HiFiScan is not intended for use with headphones. There is
|
||||||
|
the
|
||||||
|
`AutoEq project <https://github.com/jaakkopasanen/AutoEq>`_
|
||||||
|
with ready-made corrections for most headphones, Even so,
|
||||||
|
it can be used for experiments. For example, I have very
|
||||||
|
nice Dali IO-4 headphones that can be used with Bluetooth
|
||||||
|
or passively with an analog audio cable. It sounds better with
|
||||||
|
Bluetooth, which suggests that some equalization
|
||||||
|
is taking place. Lets measure this!
|
||||||
|
|
||||||
|
.. image:: images/dali.jpg
|
||||||
|
|
||||||
|
It is seen that there is a indeed a bit of active tuning
|
||||||
|
going on, although most of the tuning is done acoustically.
|
||||||
|
In orange is bluetooth and in cyan is the analog cable.
|
||||||
|
There's a wide +10dB peak at 1.8 kHz and a narrow +4dB peak at 5.5 kHz.
|
||||||
|
This tuning can be applied to the analog signal to get the same sound as
|
||||||
|
with Bluetooth.
|
||||||
|
|
||||||
|
.. image:: images/dali-spectrum.png
|
||||||
|
|
||||||
|
|
||||||
|
.. |PyPiVersion| image:: https://img.shields.io/pypi/v/hifiscan.svg
|
||||||
|
:alt: PyPi
|
||||||
|
:target: https://pypi.python.org/pypi/hifiscan
|
||||||
|
|
||||||
|
.. |PyVersion| image:: https://img.shields.io/badge/python-3.7+-blue.svg
|
||||||
|
:alt:
|
||||||
|
|
||||||
|
.. |Status| image:: https://img.shields.io/badge/status-stable-green.svg
|
||||||
|
:alt:
|
||||||
|
|
||||||
|
.. |License| image:: https://img.shields.io/badge/license-BSD-blue.svg
|
||||||
|
:alt:
|
||||||
|
|
||||||
|
|
||||||
|
Disclaimer
|
||||||
|
==========
|
||||||
|
|
||||||
|
The software is provided on the conditions of the simplified BSD license.
|
||||||
|
Any blown speakers or shattered glasses are on you.
|
||||||
|
|
||||||
|
Enjoy,
|
||||||
|
|
||||||
|
:author: Ewald de Wit <ewald.de.wit@gmail.com>
|
|
@ -0,0 +1,6 @@
|
||||||
|
"""'Optimize the frequency response spectrum of an audio system"""
|
||||||
|
|
||||||
|
from hifiscan.analyzer import (
|
||||||
|
Analyzer, XY, geom_chirp, linear_chirp, resample, smooth, taper,
|
||||||
|
tone, window)
|
||||||
|
from hifiscan.audio import Audio, write_wav
|
|
@ -0,0 +1,4 @@
|
||||||
|
from hifiscan.app import main
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
|
@ -0,0 +1,308 @@
|
||||||
|
from functools import lru_cache
|
||||||
|
from typing import NamedTuple, Tuple
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
try:
|
||||||
|
from numba import njit
|
||||||
|
except ImportError:
|
||||||
|
def njit(f):
|
||||||
|
return f
|
||||||
|
|
||||||
|
|
||||||
|
class XY(NamedTuple):
|
||||||
|
"""XY coordinate data of arrays of the same length."""
|
||||||
|
|
||||||
|
x: np.ndarray
|
||||||
|
y: np.ndarray
|
||||||
|
|
||||||
|
|
||||||
|
class Analyzer:
|
||||||
|
"""
|
||||||
|
Analyze the system response to a chirp stimulus.
|
||||||
|
|
||||||
|
Symbols that are used:
|
||||||
|
|
||||||
|
x: stimulus
|
||||||
|
y: response = x * h
|
||||||
|
X = FT(x)
|
||||||
|
Y = FT(y) = X . H
|
||||||
|
H: system transfer function = X / Y
|
||||||
|
h: system impulse response = IFT(H)
|
||||||
|
h_inv: inverse system impulse response (which undoes h) = IFT(1 / H)
|
||||||
|
|
||||||
|
with:
|
||||||
|
*: convolution operator
|
||||||
|
FT: Fourier transform
|
||||||
|
IFT: Inverse Fourier transform
|
||||||
|
"""
|
||||||
|
|
||||||
|
MAX_DELAY_SECS = 0.1
|
||||||
|
TIMEOUT_SECS = 1.0
|
||||||
|
|
||||||
|
chirp: np.ndarray
|
||||||
|
x: np.ndarray
|
||||||
|
y: np.ndarray
|
||||||
|
rate: int
|
||||||
|
secs: float
|
||||||
|
fmin: float
|
||||||
|
fmax: float
|
||||||
|
time: float
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self, f0: int, f1: int, secs: float, rate: int, ampl: float):
|
||||||
|
self.chirp = ampl * geom_chirp(f0, f1, secs, rate)
|
||||||
|
self.x = np.concatenate([
|
||||||
|
self.chirp,
|
||||||
|
np.zeros(int(self.MAX_DELAY_SECS * rate))
|
||||||
|
])
|
||||||
|
self.secs = self.x.size / rate
|
||||||
|
self.rate = rate
|
||||||
|
self.fmin = min(f0, f1)
|
||||||
|
self.fmax = max(f0, f1)
|
||||||
|
self.time: float = 0
|
||||||
|
|
||||||
|
# Cache the methods in a way that allows garbage collection of self.
|
||||||
|
for meth in ['X', 'Y', 'H', 'H2', 'h', 'h_inv', 'spectrum']:
|
||||||
|
setattr(self, meth, lru_cache(getattr(self, meth)))
|
||||||
|
|
||||||
|
def findMatch(self, recording: np.ndarray) -> bool:
|
||||||
|
"""
|
||||||
|
Use correlation to find a match of the chirp in the recording.
|
||||||
|
If found, return True and store the system respons as ``y``.
|
||||||
|
"""
|
||||||
|
self.time = recording.size / self.rate
|
||||||
|
if recording.size >= self.x.size:
|
||||||
|
Y = np.fft.fft(recording)
|
||||||
|
X = np.fft.fft(np.flip(self.x), n=recording.size)
|
||||||
|
corr = np.fft.ifft(X * Y).real
|
||||||
|
idx = int(corr.argmax()) - self.x.size + 1
|
||||||
|
if idx >= 0:
|
||||||
|
self.y = recording[idx:idx + self.x.size].copy()
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def timedOut(self) -> bool:
|
||||||
|
"""See if time to find a match has exceeded the timeout limit."""
|
||||||
|
return self.time > self.secs + self.TIMEOUT_SECS
|
||||||
|
|
||||||
|
def freqRange(self, size: int) -> slice:
|
||||||
|
"""
|
||||||
|
Return range slice of the valid frequency range for an array
|
||||||
|
of given size.
|
||||||
|
"""
|
||||||
|
nyq = self.rate / 2
|
||||||
|
i0 = int(0.5 + size * self.fmin / nyq)
|
||||||
|
i1 = int(0.5 + size * self.fmax / nyq)
|
||||||
|
return slice(i0, i1 + 1)
|
||||||
|
|
||||||
|
def X(self) -> np.ndarray:
|
||||||
|
return np.fft.rfft(self.x)
|
||||||
|
|
||||||
|
def Y(self) -> np.ndarray:
|
||||||
|
return np.fft.rfft(self.y)
|
||||||
|
|
||||||
|
def H(self) -> XY:
|
||||||
|
"""
|
||||||
|
Calculate complex-valued transfer function H in the
|
||||||
|
frequency domain.
|
||||||
|
"""
|
||||||
|
X = self.X()
|
||||||
|
Y = self.Y()
|
||||||
|
# H = Y / X
|
||||||
|
H = Y * np.conj(X) / (np.abs(X) ** 2 + 1e-3)
|
||||||
|
freq = np.linspace(0, self.rate // 2, H.size)
|
||||||
|
return XY(freq, H)
|
||||||
|
|
||||||
|
def H2(self, smoothing: float):
|
||||||
|
"""Calculate smoothed squared transfer function |H|^2."""
|
||||||
|
freq, H = self.H()
|
||||||
|
H = np.abs(H)
|
||||||
|
r = self.freqRange(H.size)
|
||||||
|
H2 = np.empty_like(H)
|
||||||
|
# Perform smoothing on the squared amplitude.
|
||||||
|
H2[r] = smooth(freq[r], H[r] ** 2, smoothing)
|
||||||
|
H2[:r.start] = H2[r.start]
|
||||||
|
H2[r.stop:] = H2[r.stop - 1]
|
||||||
|
return XY(freq, H2)
|
||||||
|
|
||||||
|
def h(self) -> XY:
|
||||||
|
"""Calculate impulse response ``h`` in the time domain."""
|
||||||
|
_, H = self.H()
|
||||||
|
h = np.fft.irfft(H)
|
||||||
|
h = np.hstack([h[h.size // 2:], h[0:h.size // 2]])
|
||||||
|
t = np.linspace(0, h.size / self.rate, h.size)
|
||||||
|
return XY(t, h)
|
||||||
|
|
||||||
|
def spectrum(self, smoothing: float = 0) -> XY:
|
||||||
|
"""
|
||||||
|
Calculate the frequency response in the valid frequency range,
|
||||||
|
with optional smoothing.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
smoothing: Determines the overall strength of the smoothing.
|
||||||
|
Useful values are from 0 to around 30.
|
||||||
|
If 0 then no smoothing is done.
|
||||||
|
"""
|
||||||
|
freq, H2 = self.H2(smoothing)
|
||||||
|
r = self.freqRange(H2.size)
|
||||||
|
return XY(freq[r], 10 * np.log10(H2[r]))
|
||||||
|
|
||||||
|
def h_inv(
|
||||||
|
self,
|
||||||
|
secs: float = 0.05,
|
||||||
|
dbRange: float = 24,
|
||||||
|
kaiserBeta: float = 5,
|
||||||
|
smoothing: float = 0) -> XY:
|
||||||
|
"""
|
||||||
|
Calculate the inverse impulse response.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
secs: Desired length of the response in seconds.
|
||||||
|
dbRange: Maximum attenuation in dB (power).
|
||||||
|
kaiserBeta: Shape parameter of the Kaiser tapering window.
|
||||||
|
smoothing: Strength of frequency-dependent smoothing.
|
||||||
|
"""
|
||||||
|
freq, H2 = self.H2(smoothing)
|
||||||
|
# Re-sample to halve the number of samples needed.
|
||||||
|
n = int(secs * self.rate / 2)
|
||||||
|
H = resample(H2, n) ** 0.5
|
||||||
|
# Accommodate the given dbRange from the top.
|
||||||
|
H /= H.max()
|
||||||
|
H = np.fmax(H, 10 ** (-dbRange / 20))
|
||||||
|
|
||||||
|
# Calculate Z, the reciprocal transfer function with added
|
||||||
|
# linear phase. This phase will shift and center z.
|
||||||
|
Z = 1 / H
|
||||||
|
phase = np.exp(Z.size * 1j * np.linspace(0, np.pi, Z.size))
|
||||||
|
Z = Z * phase
|
||||||
|
|
||||||
|
# Calculate the inverse impulse response z.
|
||||||
|
z = np.fft.irfft(Z)
|
||||||
|
z = z[:-1]
|
||||||
|
z *= window(z.size, kaiserBeta)
|
||||||
|
# Normalize using a fractal dimension for scaling.
|
||||||
|
dim = 1.6
|
||||||
|
norm = (np.abs(z) ** dim).sum() ** (1 / dim)
|
||||||
|
z /= norm
|
||||||
|
# assert np.allclose(z[-(z.size // 2):][::-1], z[:z.size // 2])
|
||||||
|
|
||||||
|
t = np.linspace(0, z.size / self.rate, z.size)
|
||||||
|
return XY(t, z)
|
||||||
|
|
||||||
|
def correctionFactor(self, invResp: np.ndarray) -> XY:
|
||||||
|
"""
|
||||||
|
Calculate correction factor for each frequency, given the
|
||||||
|
inverse impulse response.
|
||||||
|
"""
|
||||||
|
Z = np.abs(np.fft.rfft(invResp))
|
||||||
|
Z /= Z.max()
|
||||||
|
freq = np.linspace(0, self.rate / 2, Z.size)
|
||||||
|
return XY(freq, Z)
|
||||||
|
|
||||||
|
def correctedSpectrum(self, corrFactor: XY) -> Tuple[XY, XY]:
|
||||||
|
"""
|
||||||
|
Simulate the frequency response of the system when it has
|
||||||
|
been corrected with the given transfer function.
|
||||||
|
"""
|
||||||
|
freq, H2 = self.H2(0)
|
||||||
|
H = H2 ** 0.5
|
||||||
|
r = self.freqRange(H.size)
|
||||||
|
|
||||||
|
tf = resample(corrFactor.y, H.size)
|
||||||
|
resp = 20 * np.log10(tf[r] * H[r])
|
||||||
|
spectrum = XY(freq[r], resp)
|
||||||
|
|
||||||
|
H = resample(H2, corrFactor.y.size) ** 0.5
|
||||||
|
rr = self.freqRange(corrFactor.y.size)
|
||||||
|
resp = 20 * np.log10(corrFactor.y[rr] * H[rr])
|
||||||
|
spectrum_resamp = XY(corrFactor.x[rr], resp)
|
||||||
|
|
||||||
|
return spectrum, spectrum_resamp
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache
|
||||||
|
def tone(f: float, secs: float, rate: int):
|
||||||
|
"""Generate a sine wave."""
|
||||||
|
n = int(secs * f)
|
||||||
|
secs = n / f
|
||||||
|
t = np.arange(0, secs * rate) / rate
|
||||||
|
sine = np.sin(2 * np.pi * f * t)
|
||||||
|
return sine
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache
|
||||||
|
def geom_chirp(
|
||||||
|
f0: float, f1: float, secs: float, rate: int, invert: bool = False):
|
||||||
|
"""
|
||||||
|
Generate a geometric chirp (with an exponentially changing frequency).
|
||||||
|
|
||||||
|
To avoid a clicking sound at the end, the last sample should be near
|
||||||
|
zero. This is done by slightly modifying the time interval to fit an
|
||||||
|
integer number of cycli.
|
||||||
|
"""
|
||||||
|
n = int(secs * (f1 - f0) / np.log(f1 / f0))
|
||||||
|
k = np.exp((f1 - f0) / n) # =~ exp[log(f1/f0) / secs]
|
||||||
|
secs = np.log(f1 / f0) / np.log(k)
|
||||||
|
|
||||||
|
t = np.arange(0, secs * rate) / rate
|
||||||
|
chirp = np.sin(2 * np.pi * f0 * (k ** t - 1) / np.log(k))
|
||||||
|
if invert:
|
||||||
|
chirp = np.flip(chirp) / k ** t
|
||||||
|
return chirp
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache
|
||||||
|
def linear_chirp(f0: float, f1: float, secs: float, rate: int):
|
||||||
|
"""Generate a linear chirp (with a linearly changing frequency)."""
|
||||||
|
n = int(secs * (f1 + f0) / 2)
|
||||||
|
secs = 2 * n / (f1 + f0)
|
||||||
|
c = (f1 - f0) / secs
|
||||||
|
t = np.arange(0, secs * rate) / rate
|
||||||
|
chirp = np.sin(2 * np.pi * (0.5 * c * t ** 2 + f0 * t))
|
||||||
|
return chirp
|
||||||
|
|
||||||
|
|
||||||
|
def resample(a: np.ndarray, size: int) -> np.ndarray:
|
||||||
|
"""
|
||||||
|
Re-sample the array ``a`` to the given new ``size``.
|
||||||
|
"""
|
||||||
|
xp = np.linspace(0, 1, a.size)
|
||||||
|
x = np.linspace(0, 1, size)
|
||||||
|
y = np.interp(x, xp, a)
|
||||||
|
return y
|
||||||
|
|
||||||
|
|
||||||
|
@njit
|
||||||
|
def smooth(freq: np.ndarray, data: np.ndarray, smoothing: float) -> np.ndarray:
|
||||||
|
"""
|
||||||
|
Smooth the data with a smoothing strength proportional to
|
||||||
|
the given frequency array and overall smoothing factor.
|
||||||
|
The smoothing uses a double-pass exponential moving average (going
|
||||||
|
backward and forward).
|
||||||
|
"""
|
||||||
|
if not smoothing:
|
||||||
|
return data
|
||||||
|
weight = 1 / (1 + freq * 2 ** (smoothing / 2 - 15))
|
||||||
|
forward = np.empty_like(data)
|
||||||
|
backward = np.empty_like(data)
|
||||||
|
prev = data[-1]
|
||||||
|
for i, w in enumerate(np.flip(weight), 1):
|
||||||
|
backward[-i] = prev = (1 - w) * prev + w * data[-i]
|
||||||
|
prev = backward[0]
|
||||||
|
for i, w in enumerate(weight):
|
||||||
|
forward[i] = prev = (1 - w) * prev + w * backward[i]
|
||||||
|
return forward
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache
|
||||||
|
def window(size: int, beta: float) -> np.ndarray:
|
||||||
|
"""Kaiser tapering window."""
|
||||||
|
return np.kaiser(size, beta)
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache
|
||||||
|
def taper(y0: float, y1: float, size: int) -> np.ndarray:
|
||||||
|
"""Create a smooth transition from y0 to y1 of given size."""
|
||||||
|
tp = (y0 + y1 - (y1 - y0) * np.cos(np.linspace(0, np.pi, size))) / 2
|
||||||
|
return tp
|
|
@ -0,0 +1,365 @@
|
||||||
|
import asyncio
|
||||||
|
import datetime as dt
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import signal
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import PyQt5.Qt as qt
|
||||||
|
import numpy as np
|
||||||
|
import pyqtgraph as pg
|
||||||
|
|
||||||
|
import hifiscan as hifi
|
||||||
|
|
||||||
|
|
||||||
|
class App(qt.QMainWindow):
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self.setWindowTitle('HiFi Scan')
|
||||||
|
topWidget = qt.QWidget()
|
||||||
|
self.setCentralWidget(topWidget)
|
||||||
|
vbox = qt.QVBoxLayout()
|
||||||
|
topWidget.setLayout(vbox)
|
||||||
|
|
||||||
|
self.stack = qt.QStackedWidget()
|
||||||
|
self.stack.addWidget(self.createSpectrumWidget())
|
||||||
|
self.stack.addWidget(self.createIRWidget())
|
||||||
|
self.stack.currentChanged.connect(self.plot)
|
||||||
|
vbox.addWidget(self.stack)
|
||||||
|
vbox.addWidget(self.createSharedControls())
|
||||||
|
|
||||||
|
self.paused = False
|
||||||
|
self.analyzer = None
|
||||||
|
self.refAnalyzer = None
|
||||||
|
self.saveDir = Path.home()
|
||||||
|
self.loop = asyncio.get_event_loop_policy().get_event_loop()
|
||||||
|
self.task = self.loop.create_task(wrap_coro(self.analyze()))
|
||||||
|
|
||||||
|
self.resize(1800, 900)
|
||||||
|
self.show()
|
||||||
|
|
||||||
|
async def analyze(self):
|
||||||
|
with hifi.Audio() as audio:
|
||||||
|
while True:
|
||||||
|
lo = self.lo.value()
|
||||||
|
hi = self.hi.value()
|
||||||
|
secs = self.secs.value()
|
||||||
|
ampl = self.ampl.value() / 100
|
||||||
|
if self.paused or lo >= hi or secs <= 0 or not ampl:
|
||||||
|
await asyncio.sleep(0.1)
|
||||||
|
continue
|
||||||
|
|
||||||
|
analyzer = hifi.Analyzer(lo, hi, secs, audio.rate, ampl)
|
||||||
|
audio.play(analyzer.chirp)
|
||||||
|
async for recording in audio.record():
|
||||||
|
if self.paused:
|
||||||
|
audio.cancelPlay()
|
||||||
|
break
|
||||||
|
if analyzer.findMatch(recording):
|
||||||
|
self.analyzer = analyzer
|
||||||
|
self.plot()
|
||||||
|
break
|
||||||
|
if analyzer.timedOut():
|
||||||
|
break
|
||||||
|
|
||||||
|
def setPaused(self):
|
||||||
|
self.paused = not self.paused
|
||||||
|
|
||||||
|
def plot(self, *_):
|
||||||
|
if self.stack.currentIndex() == 0:
|
||||||
|
self.plotSpectrum()
|
||||||
|
else:
|
||||||
|
self.plotIR()
|
||||||
|
|
||||||
|
def plotSpectrum(self):
|
||||||
|
smoothing = self.spectrumSmoothing.value()
|
||||||
|
if self.analyzer:
|
||||||
|
spectrum = self.analyzer.spectrum(smoothing)
|
||||||
|
self.spectrumPlot.setData(*spectrum)
|
||||||
|
if self.refAnalyzer:
|
||||||
|
spectrum = self.refAnalyzer.spectrum(smoothing)
|
||||||
|
self.refSpectrumPlot.setData(*spectrum)
|
||||||
|
|
||||||
|
def plotIR(self):
|
||||||
|
if self.refAnalyzer and self.useRefBox.isChecked():
|
||||||
|
analyzer = self.refAnalyzer
|
||||||
|
else:
|
||||||
|
analyzer = self.analyzer
|
||||||
|
secs = self.msDuration.value() / 1000
|
||||||
|
dbRange = self.dbRange.value()
|
||||||
|
beta = self.kaiserBeta.value()
|
||||||
|
smoothing = self.irSmoothing.value()
|
||||||
|
|
||||||
|
t, ir = analyzer.h_inv(secs, dbRange, beta, smoothing)
|
||||||
|
self.irPlot.setData(1000 * t, ir)
|
||||||
|
|
||||||
|
logIr = np.log10(1e-8 + np.abs(ir))
|
||||||
|
self.logIrPlot.setData(1000 * t, logIr)
|
||||||
|
|
||||||
|
corrFactor = analyzer.correctionFactor(ir)
|
||||||
|
self.correctionPlot.setData(*corrFactor)
|
||||||
|
|
||||||
|
spectrum, spectrum_resamp = analyzer.correctedSpectrum(corrFactor)
|
||||||
|
self.simPlot.setData(*spectrum)
|
||||||
|
self.avSimPlot.setData(*spectrum_resamp)
|
||||||
|
|
||||||
|
def screenshot(self):
|
||||||
|
timestamp = dt.datetime.now().strftime('%Y%m%d_%H%M%S')
|
||||||
|
name = f'hifiscan_{timestamp}.png'
|
||||||
|
filename, _ = qt.QFileDialog.getSaveFileName(
|
||||||
|
self, 'Save screenshot', str(self.saveDir / name), 'PNG (*.png)')
|
||||||
|
if filename:
|
||||||
|
self.stack.grab().save(filename)
|
||||||
|
self.saveDir = Path(filename).parent
|
||||||
|
|
||||||
|
def saveIR(self):
|
||||||
|
if self.refAnalyzer and self.useRefBox.isChecked():
|
||||||
|
analyzer = self.refAnalyzer
|
||||||
|
else:
|
||||||
|
analyzer = self.analyzer
|
||||||
|
ms = int(self.msDuration.value())
|
||||||
|
db = int(self.dbRange.value())
|
||||||
|
beta = int(self.kaiserBeta.value())
|
||||||
|
smoothing = int(self.irSmoothing.value())
|
||||||
|
_, irInv = analyzer.h_inv(ms / 1000, db, beta, smoothing)
|
||||||
|
|
||||||
|
name = f'IR_{ms}ms_{db}dB_{beta}t_{smoothing}s.wav'
|
||||||
|
filename, _ = qt.QFileDialog.getSaveFileName(
|
||||||
|
self, 'Save inverse impulse response',
|
||||||
|
str(self.saveDir / name), 'WAV (*.wav)')
|
||||||
|
if filename:
|
||||||
|
hifi.write_wav(filename, analyzer.rate, irInv)
|
||||||
|
self.saveDir = Path(filename).parent
|
||||||
|
|
||||||
|
def setReference(self, withRef: bool):
|
||||||
|
if withRef:
|
||||||
|
if self.analyzer:
|
||||||
|
self.refAnalyzer = self.analyzer
|
||||||
|
self.plot()
|
||||||
|
else:
|
||||||
|
self.refAnalyzer = None
|
||||||
|
self.refSpectrumPlot.clear()
|
||||||
|
self.spectrumPlotWidget.repaint()
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
"""Run both the Qt and asyncio event loops."""
|
||||||
|
|
||||||
|
def updateQt():
|
||||||
|
qt.qApp.processEvents()
|
||||||
|
self.loop.call_later(0.03, updateQt)
|
||||||
|
|
||||||
|
signal.signal(signal.SIGINT, lambda *args: self.close())
|
||||||
|
updateQt()
|
||||||
|
self.loop.run_forever()
|
||||||
|
self.loop.run_until_complete(self.task)
|
||||||
|
os._exit(0)
|
||||||
|
|
||||||
|
def closeEvent(self, ev):
|
||||||
|
self.task.cancel()
|
||||||
|
self.loop.stop()
|
||||||
|
|
||||||
|
def createSpectrumWidget(self) -> qt.QWidget:
|
||||||
|
topWidget = qt.QWidget()
|
||||||
|
vbox = qt.QVBoxLayout()
|
||||||
|
topWidget.setLayout(vbox)
|
||||||
|
|
||||||
|
axes = {ori: Axis(ori) for ori in
|
||||||
|
['bottom', 'left', 'top', 'right']}
|
||||||
|
for ax in axes.values():
|
||||||
|
ax.setGrid(255)
|
||||||
|
self.spectrumPlotWidget = pw = pg.PlotWidget(axisItems=axes)
|
||||||
|
pw.setLabel('left', 'Relative Power [dB]')
|
||||||
|
pw.setLabel('bottom', 'Frequency [Hz]')
|
||||||
|
pw.setLogMode(x=True)
|
||||||
|
self.refSpectrumPlot = pw.plot(pen=(255, 100, 0), stepMode='right')
|
||||||
|
self.spectrumPlot = pw.plot(pen=(0, 255, 255), stepMode='right')
|
||||||
|
self.spectrumPlot.curve.setCompositionMode(
|
||||||
|
qt.QPainter.CompositionMode_Plus)
|
||||||
|
vbox.addWidget(pw)
|
||||||
|
|
||||||
|
self.lo = pg.SpinBox(
|
||||||
|
value=20, step=5, bounds=[5, 40000], suffix='Hz')
|
||||||
|
self.hi = pg.SpinBox(
|
||||||
|
value=20000, step=100, bounds=[5, 40000], suffix='Hz')
|
||||||
|
self.secs = pg.SpinBox(
|
||||||
|
value=1.0, step=0.1, bounds=[0.1, 10], suffix='s')
|
||||||
|
self.ampl = pg.SpinBox(
|
||||||
|
value=40, step=1, bounds=[0, 100], suffix='%')
|
||||||
|
self.spectrumSmoothing = pg.SpinBox(
|
||||||
|
value=15, step=1, bounds=[0, 30])
|
||||||
|
self.spectrumSmoothing.sigValueChanging.connect(self.plot)
|
||||||
|
refBox = qt.QCheckBox('Reference')
|
||||||
|
refBox.stateChanged.connect(self.setReference)
|
||||||
|
|
||||||
|
hbox = qt.QHBoxLayout()
|
||||||
|
hbox.addStretch(1)
|
||||||
|
hbox.addWidget(qt.QLabel('Low: '))
|
||||||
|
hbox.addWidget(self.lo)
|
||||||
|
hbox.addSpacing(32)
|
||||||
|
hbox.addWidget(qt.QLabel('High: '))
|
||||||
|
hbox.addWidget(self.hi)
|
||||||
|
hbox.addSpacing(32)
|
||||||
|
hbox.addWidget(qt.QLabel('Duration: '))
|
||||||
|
hbox.addWidget(self.secs)
|
||||||
|
hbox.addSpacing(32)
|
||||||
|
hbox.addWidget(qt.QLabel('Amplitude: '))
|
||||||
|
hbox.addWidget(self.ampl)
|
||||||
|
hbox.addSpacing(32)
|
||||||
|
hbox.addWidget(qt.QLabel('Smoothing: '))
|
||||||
|
hbox.addWidget(self.spectrumSmoothing)
|
||||||
|
hbox.addSpacing(32)
|
||||||
|
hbox.addWidget(refBox)
|
||||||
|
hbox.addStretch(1)
|
||||||
|
vbox.addLayout(hbox)
|
||||||
|
|
||||||
|
return topWidget
|
||||||
|
|
||||||
|
def createIRWidget(self) -> qt.QWidget:
|
||||||
|
topWidget = qt.QWidget()
|
||||||
|
vbox = qt.QVBoxLayout()
|
||||||
|
topWidget.setLayout(vbox)
|
||||||
|
splitter = qt.QSplitter(qt.Qt.Vertical)
|
||||||
|
vbox.addWidget(splitter)
|
||||||
|
|
||||||
|
self.irPlotWidget = pw = pg.PlotWidget()
|
||||||
|
pw.showGrid(True, True, 0.8)
|
||||||
|
self.irPlot = pw.plot(pen=(0, 255, 255))
|
||||||
|
pw.setLabel('left', 'Inverse IR')
|
||||||
|
splitter.addWidget(pw)
|
||||||
|
|
||||||
|
self.logIrPlotWidget = pw = pg.PlotWidget()
|
||||||
|
pw.showGrid(True, True, 0.8)
|
||||||
|
pw.setLabel('left', 'Log Inverse IR')
|
||||||
|
self.logIrPlot = pw.plot(pen=(0, 255, 100))
|
||||||
|
splitter.addWidget(pw)
|
||||||
|
|
||||||
|
self.correctionPlotWidget = pw = pg.PlotWidget()
|
||||||
|
pw.showGrid(True, True, 0.8)
|
||||||
|
pw.setLabel('left', 'Correction Factor')
|
||||||
|
self.correctionPlot = pw.plot(
|
||||||
|
pen=(255, 255, 200), fillLevel=0, fillBrush=(255, 0, 0, 100))
|
||||||
|
splitter.addWidget(pw)
|
||||||
|
|
||||||
|
axes = {ori: Axis(ori) for ori in ['bottom', 'left']}
|
||||||
|
for ax in axes.values():
|
||||||
|
ax.setGrid(255)
|
||||||
|
self.simPlotWidget = pw = pg.PlotWidget(axisItems=axes)
|
||||||
|
pw.showGrid(True, True, 0.8)
|
||||||
|
pw.setLabel('left', 'Corrected Spectrum')
|
||||||
|
self.simPlot = pg.PlotDataItem(pen=(150, 100, 60), stepMode='right')
|
||||||
|
pw.addItem(self.simPlot, ignoreBounds=True)
|
||||||
|
self.avSimPlot = pw.plot(pen=(255, 255, 200), stepMode='right')
|
||||||
|
pw.setLogMode(x=True)
|
||||||
|
splitter.addWidget(pw)
|
||||||
|
|
||||||
|
self.msDuration = pg.SpinBox(
|
||||||
|
value=50, step=1, bounds=[1, 1000], suffix='ms')
|
||||||
|
self.msDuration.sigValueChanging.connect(self.plot)
|
||||||
|
self.dbRange = pg.SpinBox(
|
||||||
|
value=24, step=1, bounds=[0, 100], suffix='dB')
|
||||||
|
self.dbRange.sigValueChanging.connect(self.plot)
|
||||||
|
self.kaiserBeta = pg.SpinBox(
|
||||||
|
value=5, step=1, bounds=[0, 100])
|
||||||
|
self.irSmoothing = pg.SpinBox(
|
||||||
|
value=15, step=1, bounds=[0, 30])
|
||||||
|
self.irSmoothing.sigValueChanging.connect(self.plot)
|
||||||
|
self.kaiserBeta.sigValueChanging.connect(self.plot)
|
||||||
|
self.useRefBox = qt.QCheckBox('Use reference')
|
||||||
|
self.useRefBox.stateChanged.connect(self.plot)
|
||||||
|
exportButton = qt.QPushButton('Export as WAV')
|
||||||
|
exportButton.setShortcut('E')
|
||||||
|
exportButton.setToolTip('<Key E>')
|
||||||
|
exportButton.clicked.connect(self.saveIR)
|
||||||
|
|
||||||
|
hbox = qt.QHBoxLayout()
|
||||||
|
hbox.addStretch(1)
|
||||||
|
hbox.addWidget(qt.QLabel('Duration: '))
|
||||||
|
hbox.addWidget(self.msDuration)
|
||||||
|
hbox.addSpacing(32)
|
||||||
|
hbox.addWidget(qt.QLabel('Range: '))
|
||||||
|
hbox.addWidget(self.dbRange)
|
||||||
|
hbox.addSpacing(32)
|
||||||
|
hbox.addWidget(qt.QLabel('Tapering: '))
|
||||||
|
hbox.addWidget(self.kaiserBeta)
|
||||||
|
hbox.addSpacing(32)
|
||||||
|
hbox.addWidget(qt.QLabel('Smoothing: '))
|
||||||
|
hbox.addWidget(self.irSmoothing)
|
||||||
|
hbox.addSpacing(32)
|
||||||
|
hbox.addWidget(self.useRefBox)
|
||||||
|
hbox.addSpacing(32)
|
||||||
|
hbox.addWidget(exportButton)
|
||||||
|
hbox.addStretch(1)
|
||||||
|
vbox.addLayout(hbox)
|
||||||
|
|
||||||
|
return topWidget
|
||||||
|
|
||||||
|
def createSharedControls(self) -> qt.QWidget:
|
||||||
|
topWidget = qt.QWidget()
|
||||||
|
vbox = qt.QVBoxLayout()
|
||||||
|
topWidget.setLayout(vbox)
|
||||||
|
|
||||||
|
self.buttons = buttons = qt.QButtonGroup()
|
||||||
|
buttons.setExclusive(True)
|
||||||
|
spectrumButton = qt.QRadioButton('Spectrum')
|
||||||
|
irButton = qt.QRadioButton('Impulse Response')
|
||||||
|
buttons.addButton(spectrumButton, 0)
|
||||||
|
buttons.addButton(irButton, 1)
|
||||||
|
spectrumButton.setChecked(True)
|
||||||
|
buttons.idClicked.connect(self.stack.setCurrentIndex)
|
||||||
|
|
||||||
|
screenshotButton = qt.QPushButton('Screenshot')
|
||||||
|
screenshotButton.setShortcut('S')
|
||||||
|
screenshotButton.setToolTip('<Key S>')
|
||||||
|
screenshotButton.clicked.connect(self.screenshot)
|
||||||
|
|
||||||
|
pauseButton = qt.QPushButton('Pause')
|
||||||
|
pauseButton.setShortcut('Space')
|
||||||
|
pauseButton.setToolTip('<Space>')
|
||||||
|
pauseButton.setFocusPolicy(qt.Qt.NoFocus)
|
||||||
|
pauseButton.clicked.connect(self.setPaused)
|
||||||
|
|
||||||
|
exitButton = qt.QPushButton('Exit')
|
||||||
|
exitButton.setShortcut('Esc')
|
||||||
|
exitButton.setToolTip('<Esc>')
|
||||||
|
exitButton.clicked.connect(self.close)
|
||||||
|
|
||||||
|
hbox = qt.QHBoxLayout()
|
||||||
|
hbox.addWidget(spectrumButton)
|
||||||
|
hbox.addSpacing(32)
|
||||||
|
hbox.addWidget(irButton)
|
||||||
|
hbox.addStretch(1)
|
||||||
|
hbox.addWidget(screenshotButton)
|
||||||
|
hbox.addSpacing(32)
|
||||||
|
hbox.addWidget(pauseButton)
|
||||||
|
hbox.addSpacing(32)
|
||||||
|
hbox.addWidget(exitButton)
|
||||||
|
vbox.addLayout(hbox)
|
||||||
|
|
||||||
|
return topWidget
|
||||||
|
|
||||||
|
|
||||||
|
class Axis(pg.AxisItem):
|
||||||
|
|
||||||
|
def logTickStrings(self, values, scale, spacing):
|
||||||
|
return [pg.siFormat(10 ** v).replace(' ', '') for v in values]
|
||||||
|
|
||||||
|
|
||||||
|
async def wrap_coro(coro):
|
||||||
|
try:
|
||||||
|
await coro
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
except Exception:
|
||||||
|
logging.getLogger('hifiscan').exception('Error in task:')
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
_ = qt.QApplication(sys.argv)
|
||||||
|
app = App()
|
||||||
|
app.run()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
|
@ -0,0 +1,109 @@
|
||||||
|
import array
|
||||||
|
import asyncio
|
||||||
|
import sys
|
||||||
|
import wave
|
||||||
|
from collections import deque
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import AsyncIterator, Deque
|
||||||
|
|
||||||
|
import eventkit as ev
|
||||||
|
import numpy as np
|
||||||
|
import sounddevice as sd
|
||||||
|
|
||||||
|
|
||||||
|
class Audio:
|
||||||
|
"""
|
||||||
|
Bidirectional audio interface, for simultaneous playing and recording.
|
||||||
|
|
||||||
|
Events:
|
||||||
|
* recorded(record):
|
||||||
|
Emits a new piece of recorded sound as a numpy float array.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.recorded = ev.Event()
|
||||||
|
self.playQ: Deque[PlayItem] = deque()
|
||||||
|
self.stream = sd.Stream(
|
||||||
|
channels=1,
|
||||||
|
callback=self._onStream)
|
||||||
|
self.stream.start()
|
||||||
|
self.rate = self.stream.samplerate
|
||||||
|
self.loop = asyncio.get_event_loop_policy().get_event_loop()
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *exc):
|
||||||
|
self.close()
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
self.stream.stop()
|
||||||
|
self.stream.close()
|
||||||
|
|
||||||
|
def _onStream(self, in_data, out_data, frames, _time, _status):
|
||||||
|
# Note that this is called from a non-main thread.
|
||||||
|
out_data.fill(0)
|
||||||
|
idx = 0
|
||||||
|
while self.playQ and idx < frames:
|
||||||
|
playItem = self.playQ[0]
|
||||||
|
chunk = playItem.pop(frames - idx)
|
||||||
|
idx2 = idx + chunk.size
|
||||||
|
out_data[idx:idx2, 0] = chunk
|
||||||
|
idx = idx2
|
||||||
|
if not playItem.remaining():
|
||||||
|
self.playQ.popleft()
|
||||||
|
self.recorded.emit_threadsafe(in_data)
|
||||||
|
|
||||||
|
def play(self, sound: np.ndarray):
|
||||||
|
"""Add a sound to the play queue."""
|
||||||
|
self.playQ.append(PlayItem(sound))
|
||||||
|
|
||||||
|
def cancelPlay(self):
|
||||||
|
"""Clear the play queue."""
|
||||||
|
self.playQ.clear()
|
||||||
|
|
||||||
|
def isPlaying(self) -> bool:
|
||||||
|
"""Is there sound playing from the play queue?"""
|
||||||
|
return bool(self.playQ)
|
||||||
|
|
||||||
|
def record(self) -> AsyncIterator[np.ndarray]:
|
||||||
|
"""
|
||||||
|
Start a recording, yielding the entire recording every time a
|
||||||
|
new chunk is added. Note: The yielded array holds a memory reference
|
||||||
|
that is only valid until the next chunk is added.
|
||||||
|
"""
|
||||||
|
arr = array.array('f')
|
||||||
|
return self.recorded.map(arr.extend).map(
|
||||||
|
lambda _: np.frombuffer(arr, 'f')).aiter(skip_to_last=True)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class PlayItem:
|
||||||
|
sound: np.ndarray
|
||||||
|
index: int = 0
|
||||||
|
|
||||||
|
def remaining(self) -> int:
|
||||||
|
return self.sound.size - self.index
|
||||||
|
|
||||||
|
def pop(self, num: int) -> np.ndarray:
|
||||||
|
idx = self.index + min(num, self.remaining())
|
||||||
|
chunk = self.sound[self.index:idx]
|
||||||
|
self.index = idx
|
||||||
|
return chunk
|
||||||
|
|
||||||
|
|
||||||
|
def write_wav(path: str, rate: int, sound: np.ndarray):
|
||||||
|
"""
|
||||||
|
Write a 1-channel float array with values between -1 and 1
|
||||||
|
as a 32 bit stereo wave file.
|
||||||
|
"""
|
||||||
|
scaling = 2**31 - 1
|
||||||
|
mono = np.asarray(sound * scaling, np.int32)
|
||||||
|
if sys.byteorder == 'big':
|
||||||
|
mono = mono.byteswap()
|
||||||
|
stereo = np.vstack([mono, mono]).flatten(order='F')
|
||||||
|
with wave.open(path, 'wb') as wav:
|
||||||
|
wav.setnchannels(2)
|
||||||
|
wav.setsampwidth(4)
|
||||||
|
wav.setframerate(rate)
|
||||||
|
wav.writeframes(stereo)
|
Po Szerokość: | Wysokość: | Rozmiar: 72 KiB |
Po Szerokość: | Wysokość: | Rozmiar: 55 KiB |
Po Szerokość: | Wysokość: | Rozmiar: 34 KiB |
Po Szerokość: | Wysokość: | Rozmiar: 83 KiB |
Po Szerokość: | Wysokość: | Rozmiar: 60 KiB |
Po Szerokość: | Wysokość: | Rozmiar: 61 KiB |
Po Szerokość: | Wysokość: | Rozmiar: 48 KiB |
Po Szerokość: | Wysokość: | Rozmiar: 43 KiB |
Po Szerokość: | Wysokość: | Rozmiar: 39 KiB |
Po Szerokość: | Wysokość: | Rozmiar: 68 KiB |
Po Szerokość: | Wysokość: | Rozmiar: 72 KiB |
Po Szerokość: | Wysokość: | Rozmiar: 70 KiB |
Po Szerokość: | Wysokość: | Rozmiar: 99 KiB |
Po Szerokość: | Wysokość: | Rozmiar: 73 KiB |
Po Szerokość: | Wysokość: | Rozmiar: 64 KiB |
|
@ -0,0 +1,2 @@
|
||||||
|
[mypy]
|
||||||
|
ignore_missing_imports = True
|
|
@ -0,0 +1,3 @@
|
||||||
|
[flake8]
|
||||||
|
application_import_names=hifiscan
|
||||||
|
ignore = D100,D101,D102,D103,D105,D107,D200,D205,D400,D401,W503,F401,I201
|
|
@ -0,0 +1,35 @@
|
||||||
|
import os
|
||||||
|
from setuptools import setup
|
||||||
|
|
||||||
|
here = os.path.abspath(os.path.dirname(__file__))
|
||||||
|
with open("README.rst", 'r', encoding="utf-8") as f:
|
||||||
|
long_description = f.read()
|
||||||
|
|
||||||
|
setup(
|
||||||
|
name='hifiscan',
|
||||||
|
version='1.0.0',
|
||||||
|
description='Optimize the audio quality of loudspeakers',
|
||||||
|
long_description=long_description,
|
||||||
|
packages=['hifiscan'],
|
||||||
|
url='https://github.com/erdewit/hifiscan',
|
||||||
|
author='Ewald R. de Wit',
|
||||||
|
author_email='ewald.de.wit@gmail.com',
|
||||||
|
license='BSD',
|
||||||
|
classifiers=[
|
||||||
|
'Development Status :: 5 - Stable',
|
||||||
|
'Intended Audience :: End Users/Desktop',
|
||||||
|
'Topic :: Multimedia :: Sound/Audio :: Analysis',
|
||||||
|
'License :: OSI Approved :: BSD License',
|
||||||
|
'Programming Language :: Python :: 3.7',
|
||||||
|
'Programming Language :: Python :: 3.8',
|
||||||
|
'Programming Language :: Python :: 3.9',
|
||||||
|
'Programming Language :: Python :: 3.10',
|
||||||
|
'Programming Language :: Python :: 3 :: Only',
|
||||||
|
],
|
||||||
|
keywords='frequency impulse response audio spectrum equalizer',
|
||||||
|
entry_points={
|
||||||
|
'gui_scripts': ['hifiscan=hifiscan.app:main']
|
||||||
|
},
|
||||||
|
python_requires=">=3.7",
|
||||||
|
install_requires=['numpy', 'PyQt5', 'pyqtgraph', 'sounddevice']
|
||||||
|
)
|
|
@ -0,0 +1,4 @@
|
||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
mypy hifiscan
|
||||||
|
flake8 hifiscan
|