diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 81232b2b..ea7fb7b1 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -9,7 +9,31 @@ on:
pull_request:
jobs:
+ test-linux:
+ runs-on: ubuntu-22.04
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v5
+
+ - name: Set up Python
+ uses: actions/setup-python@v6
+
+ - name: Install Qt and audio build dependencies
+ run: |
+ sudo apt-get update
+ sudo apt-get install -y \
+ libxkbcommon-x11-0 \
+ libxcb-xinerama0 \
+ libasound2-dev \
+ libjack-dev \
+ libportaudio2
+
+ - name: Run unit tests with coverage
+ run: bash ./.github/workflows/test-linux.sh
+
build-windows:
+ if: startsWith(github.ref, 'refs/tags/v')
runs-on: windows-2022
steps:
@@ -69,6 +93,7 @@ jobs:
if-no-files-found: error
build-linux:
+ if: startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-22.04
steps:
@@ -92,6 +117,7 @@ jobs:
if-no-files-found: error
build-macos:
+ if: startsWith(github.ref, 'refs/tags/v')
runs-on: macos-13
steps:
@@ -115,6 +141,7 @@ jobs:
if-no-files-found: error
release:
+ if: startsWith(github.ref, 'refs/tags/v')
name: Create release and upload artifacts
runs-on: ubuntu-latest
needs: [build-windows, build-linux, build-macos]
diff --git a/.github/workflows/test-linux.sh b/.github/workflows/test-linux.sh
new file mode 100755
index 00000000..3036d288
--- /dev/null
+++ b/.github/workflows/test-linux.sh
@@ -0,0 +1,14 @@
+#!/bin/bash
+
+set -euxo pipefail
+
+export QT_QPA_PLATFORM=offscreen
+export QT_QUICK_CONTROLS_STYLE=Fusion
+
+python3 -m pip install --upgrade pip
+python3 -m pip install -e ".[dev]"
+
+python3 setup.py build_ext --inplace
+
+python3 -m coverage run --source=friture friture/test/runner.py
+python3 -m coverage report --fail-under=22
diff --git a/.gitignore b/.gitignore
index 75344bfc..0a75fd40 100644
--- a/.gitignore
+++ b/.gitignore
@@ -41,6 +41,7 @@ Notepad++
.qt_for_python
.mypy_cache
+.coverage
# Macos
.DS_Store
\ No newline at end of file
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 00000000..5313c950
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,17 @@
+# Friture
+
+Real-time audio visualization and analysis (PyQt5, Python).
+
+## Agent skills
+
+### Issue tracker
+
+GitHub Issues on `ericdahl-dev/friture` via the `gh` CLI (`--repo ericdahl-dev/friture`). See `docs/agents/issue-tracker.md`.
+
+### Triage labels
+
+Five canonical triage roles mapped to GitHub labels (defaults). See `docs/agents/triage-labels.md`.
+
+### Domain docs
+
+Single-context layout: `CONTEXT.md` at repo root, ADRs in `docs/adr/`. See `docs/agents/domain.md`.
diff --git a/docs/agents/domain.md b/docs/agents/domain.md
new file mode 100644
index 00000000..60135957
--- /dev/null
+++ b/docs/agents/domain.md
@@ -0,0 +1,50 @@
+# Domain Docs
+
+How the engineering skills should consume this repo's domain documentation when exploring the codebase.
+
+## Before exploring, read these
+
+- **`CONTEXT.md`** at the repo root, or
+- **`CONTEXT-MAP.md`** at the repo root if it exists — it points at one `CONTEXT.md` per context. Read each one relevant to the topic.
+- **`docs/adr/`** — read ADRs that touch the area you're about to work in. In multi-context repos, also check `src//docs/adr/` for context-scoped decisions.
+
+If any of these files don't exist, **proceed silently**. Don't flag their absence; don't suggest creating them upfront. The producer skill (`/grill-with-docs`) creates them lazily when terms or decisions actually get resolved.
+
+## File structure
+
+Single-context repo:
+
+```
+/
+├── CONTEXT.md ← not created yet; add when domain terms stabilize
+├── docs/adr/ ← not created yet
+├── docs/agents/ ← agent skills configuration (this folder)
+└── friture/ ← application source
+```
+
+## Dock analysis widget
+
+Dock-hosted analyzers implement **`DockAnalysisWidget`** (`friture/dock_analysis_widget.py`):
+
+- `set_buffer` / `handle_new_data` — audio path from shared `AudioBuffer`
+- `canvasUpdate` — display timer refresh (~25 ms)
+- `pause` / `restart` — optional; `Dock` skips if missing
+- `saveState` / `restoreState` / `settings_called` / `qml_file_name` / `view_model`
+
+FFT-style docks should read history via **`RingBufferFrameReader`** (`friture/ring_buffer_frame_reader.py`). Spectrum FFT/smoothing/peak logic lives in **`SpectrumFrameAnalyzer`** (`friture/spectrum_frame_analyzer.py`) — test without Qt. Chunk-only docks (levels, octave spectrum) may consume the latest `floatdata` chunk directly.
+
+Integration tests should use ``AudioHarness`` + ``wire_dock_analysis_widget``
+in `friture/test/helpers.py`. Settings tests inject ``InputDeviceCatalog`` directly
+(see `friture/input_device_catalog.py`) — no need to patch ``get_audio_ingest``.
+
+## Use the glossary's vocabulary
+
+When your output names a domain concept (in an issue title, a refactor proposal, a hypothesis, a test name), use the term as defined in `CONTEXT.md`. Don't drift to synonyms the glossary explicitly avoids.
+
+If the concept you need isn't in the glossary yet, that's a signal — either you're inventing language the project doesn't use (reconsider) or there's a real gap (note it for `/grill-with-docs`).
+
+## Flag ADR conflicts
+
+If your output contradicts an existing ADR, surface it explicitly rather than silently overriding:
+
+> _Contradicts ADR-0007 (event-sourced orders) — but worth reopening because…_
diff --git a/docs/agents/issue-tracker.md b/docs/agents/issue-tracker.md
new file mode 100644
index 00000000..488c23f4
--- /dev/null
+++ b/docs/agents/issue-tracker.md
@@ -0,0 +1,22 @@
+# Issue tracker: GitHub
+
+Issues and PRDs for this repo live as GitHub issues on **`ericdahl-dev/friture`**. Use the `gh` CLI for all operations.
+
+This clone also has `upstream` (`tlecomte/friture`). When working on upstream bugs or contributing back, read upstream issues with `--repo tlecomte/friture`; create and triage work for this fork with `--repo ericdahl-dev/friture` unless the user says otherwise.
+
+## Conventions
+
+- **Create an issue**: `gh issue create --repo ericdahl-dev/friture --title "..." --body "..."`. Use a heredoc for multi-line bodies.
+- **Read an issue**: `gh issue view --repo ericdahl-dev/friture --comments`, filtering comments by `jq` and also fetching labels.
+- **List issues**: `gh issue list --repo ericdahl-dev/friture --state open --json number,title,body,labels,comments --jq '[.[] | {number, title, body, labels: [.labels[].name], comments: [.comments[].body]}]'` with appropriate `--label` and `--state` filters.
+- **Comment on an issue**: `gh issue comment --repo ericdahl-dev/friture --body "..."`
+- **Apply / remove labels**: `gh issue edit --repo ericdahl-dev/friture --add-label "..."` / `--remove-label "..."`
+- **Close**: `gh issue close --repo ericdahl-dev/friture --comment "..."`
+
+## When a skill says "publish to the issue tracker"
+
+Create a GitHub issue on `ericdahl-dev/friture`.
+
+## When a skill says "fetch the relevant ticket"
+
+Run `gh issue view --repo ericdahl-dev/friture --comments`.
diff --git a/docs/agents/triage-labels.md b/docs/agents/triage-labels.md
new file mode 100644
index 00000000..151992c0
--- /dev/null
+++ b/docs/agents/triage-labels.md
@@ -0,0 +1,15 @@
+# Triage Labels
+
+The skills speak in terms of five canonical triage roles. This file maps those roles to the actual label strings used in this repo's issue tracker (`ericdahl-dev/friture`).
+
+| Label in mattpocock/skills | Label in our tracker | Meaning |
+| -------------------------- | -------------------- | ---------------------------------------- |
+| `needs-triage` | `needs-triage` | Maintainer needs to evaluate this issue |
+| `needs-info` | `needs-info` | Waiting on reporter for more information |
+| `ready-for-agent` | `ready-for-agent` | Fully specified, ready for an AFK agent |
+| `ready-for-human` | `ready-for-human` | Requires human implementation |
+| `wontfix` | `wontfix` | Will not be actioned |
+
+When a skill mentions a role (e.g. "apply the AFK-ready triage label"), use the corresponding label string from this table.
+
+The fork also has GitHub default labels (`bug`, `enhancement`, `documentation`, etc.). Use those for **category**; use the triage labels above for **workflow state**.
diff --git a/friture/ControlBar.qml b/friture/ControlBar.qml
index 97f0e98a..4f61ac2e 100644
--- a/friture/ControlBar.qml
+++ b/friture/ControlBar.qml
@@ -21,7 +21,8 @@ RowLayout {
"Generator",
"Delay Estimator",
"Long-time levels",
- "Pitch Tracker"
+ "Pitch Tracker",
+ "dB levels"
]
currentIndex: viewModel.currentIndex
onCurrentIndexChanged: viewModel.currentIndex = currentIndex
diff --git a/friture/DbLevelsDock.qml b/friture/DbLevelsDock.qml
new file mode 100644
index 00000000..5d0d42b4
--- /dev/null
+++ b/friture/DbLevelsDock.qml
@@ -0,0 +1,199 @@
+import QtQuick 2.15
+import QtQuick.Layouts 1.15
+import Friture 1.0
+
+Rectangle {
+ id: root
+ anchors.fill: parent
+ SystemPalette { id: systemPalette; colorGroup: SystemPalette.Active }
+ color: systemPalette.window
+
+ required property var viewModel
+ required property string fixedFont
+
+ readonly property int captionSize: Math.max(11, Math.round(Math.min(width, height) / 22))
+ readonly property int monoValuePixelSize: Math.max(
+ 32, Math.min(Math.floor(Math.min(width, height) / 3.5), 200))
+ readonly property int stereoValuePixelSize: fitValuePixelSize(stereoSampleText())
+
+ readonly property real layoutMargin: Math.max(10, Math.min(width, height) * 0.04)
+ readonly property real layoutSpacing: Math.max(6, height * 0.02)
+
+ FontMetrics {
+ id: valueFontMetrics
+ font.family: fixedFont
+ font.bold: true
+ }
+
+ function stereoSampleText() {
+ return level_to_text(viewModel.level_data_slow.level_max)
+ + level_to_text(viewModel.level_data_slow_2.level_max);
+ }
+
+ function fitValuePixelSize(sampleText) {
+ var margin = layoutMargin;
+ var columnWidth = Math.max(40, (width - 2 * margin) / 3);
+ var size = Math.max(24, Math.min(Math.floor(Math.min(width, height) / 5.5), 120));
+ valueFontMetrics.font.pixelSize = size;
+ while (size > 20 && valueFontMetrics.advanceWidth(sampleText) > columnWidth * 0.9) {
+ size -= 2;
+ valueFontMetrics.font.pixelSize = size;
+ }
+ return size;
+ }
+
+ function level_to_text(dB) {
+ if (dB < -150.) {
+ return "-Inf";
+ }
+ return dB.toFixed(1);
+ }
+
+ function peakCaption() {
+ return "PEAK · " + viewModel.unit_label + viewModel.weighting_suffix;
+ }
+
+ function rmsCaption() {
+ return "RMS · " + viewModel.unit_label + viewModel.weighting_suffix;
+ }
+
+ // Single input: large centered readout
+ ColumnLayout {
+ visible: !viewModel.two_channels
+ anchors.fill: parent
+ anchors.margins: layoutMargin
+ spacing: layoutSpacing
+
+ Text {
+ text: peakCaption()
+ font.family: fixedFont
+ font.pixelSize: captionSize
+ font.capitalization: Font.AllUppercase
+ color: systemPalette.windowText
+ opacity: 0.85
+ Layout.fillWidth: true
+ horizontalAlignment: Text.AlignHCenter
+ }
+
+ Text {
+ Layout.fillWidth: true
+ Layout.fillHeight: true
+ Layout.minimumHeight: 48
+ font.pixelSize: monoValuePixelSize
+ font.bold: true
+ font.family: fixedFont
+ color: systemPalette.windowText
+ horizontalAlignment: Text.AlignHCenter
+ verticalAlignment: Text.AlignVCenter
+ text: level_to_text(viewModel.level_data_slow.level_max)
+ }
+
+ Text {
+ text: rmsCaption()
+ font.family: fixedFont
+ font.pixelSize: captionSize
+ font.capitalization: Font.AllUppercase
+ color: systemPalette.windowText
+ opacity: 0.85
+ Layout.fillWidth: true
+ horizontalAlignment: Text.AlignHCenter
+ }
+
+ Text {
+ Layout.fillWidth: true
+ Layout.fillHeight: true
+ Layout.minimumHeight: 48
+ font.pixelSize: monoValuePixelSize
+ font.bold: true
+ font.family: fixedFont
+ color: systemPalette.windowText
+ horizontalAlignment: Text.AlignHCenter
+ verticalAlignment: Text.AlignVCenter
+ text: level_to_text(viewModel.level_data_slow.level_rms)
+ }
+ }
+
+ // Two inputs: one column per channel, captions in the middle
+ GridLayout {
+ visible: viewModel.two_channels
+ anchors.fill: parent
+ anchors.margins: layoutMargin
+ columns: 3
+ rowSpacing: layoutSpacing
+ columnSpacing: 4
+
+ Text {
+ Layout.fillWidth: true
+ Layout.fillHeight: true
+ Layout.minimumHeight: 48
+ font.pixelSize: stereoValuePixelSize
+ font.bold: true
+ font.family: fixedFont
+ color: systemPalette.windowText
+ horizontalAlignment: Text.AlignHCenter
+ verticalAlignment: Text.AlignVCenter
+ text: level_to_text(viewModel.level_data_slow.level_max)
+ }
+
+ Text {
+ text: peakCaption()
+ font.family: fixedFont
+ font.pixelSize: captionSize
+ font.capitalization: Font.AllUppercase
+ color: systemPalette.windowText
+ opacity: 0.85
+ horizontalAlignment: Text.AlignHCenter
+ verticalAlignment: Text.AlignVCenter
+ }
+
+ Text {
+ Layout.fillWidth: true
+ Layout.fillHeight: true
+ Layout.minimumHeight: 48
+ font.pixelSize: stereoValuePixelSize
+ font.bold: true
+ font.family: fixedFont
+ color: systemPalette.windowText
+ horizontalAlignment: Text.AlignHCenter
+ verticalAlignment: Text.AlignVCenter
+ text: level_to_text(viewModel.level_data_slow_2.level_max)
+ }
+
+ Text {
+ Layout.fillWidth: true
+ Layout.fillHeight: true
+ Layout.minimumHeight: 48
+ font.pixelSize: stereoValuePixelSize
+ font.bold: true
+ font.family: fixedFont
+ color: systemPalette.windowText
+ horizontalAlignment: Text.AlignHCenter
+ verticalAlignment: Text.AlignVCenter
+ text: level_to_text(viewModel.level_data_slow.level_rms)
+ }
+
+ Text {
+ text: rmsCaption()
+ font.family: fixedFont
+ font.pixelSize: captionSize
+ font.capitalization: Font.AllUppercase
+ color: systemPalette.windowText
+ opacity: 0.85
+ horizontalAlignment: Text.AlignHCenter
+ verticalAlignment: Text.AlignVCenter
+ }
+
+ Text {
+ Layout.fillWidth: true
+ Layout.fillHeight: true
+ Layout.minimumHeight: 48
+ font.pixelSize: stereoValuePixelSize
+ font.bold: true
+ font.family: fixedFont
+ color: systemPalette.windowText
+ horizontalAlignment: Text.AlignHCenter
+ verticalAlignment: Text.AlignVCenter
+ text: level_to_text(viewModel.level_data_slow_2.level_rms)
+ }
+ }
+}
diff --git a/friture/Dock.qml b/friture/Dock.qml
index 1dfbecf7..c11d6284 100644
--- a/friture/Dock.qml
+++ b/friture/Dock.qml
@@ -25,6 +25,7 @@ Rectangle {
Layout.fillWidth: true
Layout.fillHeight: true
Layout.margins: 5
+ clip: true
}
}
}
diff --git a/friture/FritureMainWindow.qml b/friture/FritureMainWindow.qml
index acbc6ed7..7b66fb93 100644
--- a/friture/FritureMainWindow.qml
+++ b/friture/FritureMainWindow.qml
@@ -6,12 +6,14 @@ import Friture 1.0
Rectangle { // eventually move to ApplicationWindow
id: mainWindow
anchors.fill: parent
- // title: qsTr("Friture") // ApplicationWindow
- // icon.source: "qrc:/images-src/window-icon.svg" // ApplicationWindow
required property MainWindowViewModel main_window_view_model
required property string fixedFont
+ SystemPalette { id: systemPalette; colorGroup: SystemPalette.Active }
+
+ color: systemPalette.window
+
ColumnLayout { // remove once we use ApplicationWindow
anchors.fill: parent
spacing: 0
@@ -32,6 +34,7 @@ Rectangle { // eventually move to ApplicationWindow
ToolTip.text: qsTr("Start/Stop")
icon.height: 32
icon.width: 32
+ icon.color: systemPalette.windowText
//shortcut: "Space"
onClicked: {
mainWindow.main_window_view_model.toolbar_view_model.recording_toggle()
@@ -44,6 +47,7 @@ Rectangle { // eventually move to ApplicationWindow
ToolTip.text: qsTr("Add a new dock to Friture window")
icon.height: 32
icon.width: 32
+ icon.color: systemPalette.windowText
onClicked: {
mainWindow.main_window_view_model.toolbar_view_model.new_dock()
}
@@ -55,6 +59,7 @@ Rectangle { // eventually move to ApplicationWindow
ToolTip.text: qsTr("Display settings dialog")
icon.height: 32
icon.width: 32
+ icon.color: systemPalette.windowText
onClicked: {
mainWindow.main_window_view_model.toolbar_view_model.settings()
}
@@ -65,6 +70,7 @@ Rectangle { // eventually move to ApplicationWindow
text: qsTr("About Friture")
icon.height: 32
icon.width: 32
+ icon.color: systemPalette.windowText
onClicked: {
mainWindow.main_window_view_model.toolbar_view_model.about()
}
diff --git a/friture/Generator.qml b/friture/Generator.qml
index e4d5bc0a..d67f0430 100644
--- a/friture/Generator.qml
+++ b/friture/Generator.qml
@@ -31,6 +31,7 @@ Rectangle {
checked: viewModel.isPlaying
onCheckedChanged: viewModel.isPlaying = checked
icon.source: viewModel.isPlaying ? "qrc:/images-src/stop.svg" : "qrc:/images-src/start.svg"
+ icon.color: systemPalette.windowText
}
SineSettings {
diff --git a/friture/Levels.qml b/friture/Levels.qml
index ced45c65..41ef41f8 100644
--- a/friture/Levels.qml
+++ b/friture/Levels.qml
@@ -46,7 +46,7 @@ Rectangle {
Text {
id: peakLegend
textFormat: Text.PlainText
- text: "dB FS\nPeak"
+ text: level_view_model.unit_label + "\nPeak"
verticalAlignment: Text.AlignTop
horizontalAlignment: Text.AlignRight
Layout.alignment: Qt.AlignVCenter | Qt.AlignRight
@@ -69,7 +69,7 @@ Rectangle {
Text {
id: rmsLegend
textFormat: Text.PlainText
- text: "dB FS\nRMS"
+ text: level_view_model.unit_label + "\nRMS"
verticalAlignment: Text.AlignTop
horizontalAlignment: Text.AlignRight
Layout.alignment: Qt.AlignVCenter | Qt.AlignRight
diff --git a/friture/LevelsMeter.qml b/friture/LevelsMeter.qml
index dbdbc390..53a700cd 100644
--- a/friture/LevelsMeter.qml
+++ b/friture/LevelsMeter.qml
@@ -9,38 +9,42 @@ Rectangle {
required property LevelViewModel level_view_model
+ readonly property bool ready: level_view_model !== null
+
RowLayout
{
id: metersLayout
anchors.fill: parent
spacing: 0
+ visible: ready
readonly property int topOffset: height/20
SingleMeter {
Layout.fillHeight: true
Layout.alignment: Qt.AlignLeft
- levelMax: level_view_model.level_data.level_max
- levelRms: level_view_model.level_data.level_rms
+ levelMaxIec: ready ? level_view_model.level_data.level_max_iec : 0
+ levelRmsIec: ready ? level_view_model.level_data.level_rms_iec : 0
topOffset: metersLayout.topOffset
- levelIECMaxBallistic: level_view_model.level_data_ballistic.peak_iec
+ levelIECMaxBallistic: ready ? level_view_model.level_data_ballistic.peak_iec : 0
}
MeterScale {
Layout.fillHeight: true
Layout.alignment: Qt.AlignLeft
topOffset: metersLayout.topOffset
- twoChannels: level_view_model.two_channels
+ twoChannels: ready && level_view_model.two_channels
+ unitLabel: ready ? level_view_model.unit_label : "dB FS"
}
SingleMeter {
- visible: level_view_model.two_channels
+ visible: ready && level_view_model.two_channels
Layout.fillHeight: true
Layout.alignment: Qt.AlignLeft
- levelMax: level_view_model.level_data_2.level_max
- levelRms: level_view_model.level_data_2.level_rms
+ levelMaxIec: ready ? level_view_model.level_data_2.level_max_iec : 0
+ levelRmsIec: ready ? level_view_model.level_data_2.level_rms_iec : 0
topOffset: metersLayout.topOffset
- levelIECMaxBallistic: level_view_model.level_data_ballistic_2.peak_iec
+ levelIECMaxBallistic: ready ? level_view_model.level_data_ballistic_2.peak_iec : 0
}
Item {
diff --git a/friture/MeterScale.qml b/friture/MeterScale.qml
index 9521e46e..c0e41a5e 100644
--- a/friture/MeterScale.qml
+++ b/friture/MeterScale.qml
@@ -10,28 +10,17 @@ Item {
required property int topOffset
required property bool twoChannels
+ required property string unitLabel
- ListModel {
- id: scaleModel
-
- ListElement { dB: 0 }
- ListElement { dB: -3 }
- ListElement { dB: -6 }
- ListElement { dB: -10 }
- ListElement { dB: -20 }
- ListElement { dB: -30 }
- ListElement { dB: -40 }
- ListElement { dB: -50 }
- ListElement { dB: -60 }
- }
+ readonly property var scaleDbValues: IECFunctions.meterScaleTicks(unitLabel)
Repeater {
- model: scaleModel
+ model: scaleDbValues
Item {
implicitWidth: 16
x: 0
- y: pathY(dB)
+ y: pathY(modelData)
Shape {
ShapePath {
@@ -44,7 +33,7 @@ Item {
}
Text {
- text: Math.abs(dB)
+ text: Math.abs(modelData)
font.pointSize: 6
x: 0
width: 16
@@ -68,8 +57,8 @@ Item {
}
function pathY(dB) {
- var iec = IECFunctions.dB_to_IEC(dB);
- return Math.round((metersLayout.height - meterScale.topOffset) * (1. - iec) + meterScale.topOffset)
+ var fraction = IECFunctions.level_db_to_meter_fraction(dB, unitLabel);
+ return Math.round((metersLayout.height - meterScale.topOffset) * (1. - fraction) + meterScale.topOffset)
}
}
}
diff --git a/friture/SingleMeter.qml b/friture/SingleMeter.qml
index 2851040e..899e1611 100644
--- a/friture/SingleMeter.qml
+++ b/friture/SingleMeter.qml
@@ -7,8 +7,8 @@ Rectangle {
color: "black"
implicitWidth: 16
- required property double levelMax
- required property double levelRms
+ required property double levelMaxIec
+ required property double levelRmsIec
required property double levelIECMaxBallistic
required property int topOffset
@@ -30,7 +30,7 @@ Rectangle {
Item {
implicitWidth: parent.width
- implicitHeight: IECFunctions.dB_to_IEC(levelMax) * (parent.height - topOffset)
+ implicitHeight: levelMaxIec * (parent.height - topOffset)
anchors.bottom: parent.bottom
clip: true
@@ -46,7 +46,7 @@ Rectangle {
Item {
implicitWidth: parent.width
- implicitHeight: IECFunctions.dB_to_IEC(levelRms) * (parent.height - topOffset)
+ implicitHeight: levelRmsIec * (parent.height - topOffset)
anchors.bottom: parent.bottom
clip: true
diff --git a/friture/__init__.py b/friture/__init__.py
index 16d100fd..a40956ff 100644
--- a/friture/__init__.py
+++ b/friture/__init__.py
@@ -18,7 +18,7 @@
# along with Friture. If not, see .
# version and date
-__version__ = "0.54"
-__releasedate__ = "2025-09-14"
+__version__ = "0.56"
+__releasedate__ = "2026-06-12"
__all__ = ["plotting"]
diff --git a/friture/analyzer.py b/friture/analyzer.py
index e8452dcc..302806c9 100755
--- a/friture/analyzer.py
+++ b/friture/analyzer.py
@@ -40,17 +40,20 @@
from friture.exceptionhandler import errorBox, fileexcepthook
import friture
from friture.main_toolbar_view_model import MainToolbarViewModel
+from friture.global_calibration import GlobalCalibrationService
from friture.playback.playback_control_view_model import PlaybackControlViewModel
from friture.ui_friture import Ui_MainWindow
from friture.about import About_Dialog # About dialog
-from friture.settings import Settings_Dialog # Setting dialog
+from friture.input_device_catalog import require_input_devices
+from friture.settings import Settings_Dialog, splash_enabled # Setting dialog
from friture.audiobuffer import AudioBuffer # audio ring buffer class
-from friture.audiobackend import AudioBackend # audio backend class
+from friture.audio_ingest import get_audio_ingest
from friture.dockmanager import DockManager
from friture.tilelayout import TileLayout
from friture.level_view_model import LevelViewModel
from friture.level_data import LevelData
from friture.levels import Levels_Widget
+from friture.level_meter import calibration_raw_rms_db, raw_rms_db_from_buffer
from friture.main_window_view_model import MainWindowViewModel
from friture.store import GetStore, Store
from friture.scope_data import Scope_Data
@@ -84,6 +87,43 @@
SLOW_TIMER_PERIOD_MS = 1000
+def _linux_apply_dark_palette(app: QApplication, logger: logging.Logger) -> None:
+ from PyQt5.QtGui import QPalette, QColor
+
+ if os.environ.get("FRITURE_LIGHT_THEME", "").lower() in ("1", "true", "yes"):
+ return
+
+ style_override = os.environ.get("QT_STYLE_OVERRIDE", "").lower()
+ current_style = app.style().objectName().lower()
+ if style_override == "kvantum" and current_style == "kvantum":
+ logger.info("Using system Kvantum theme")
+ return
+
+ if style_override == "kvantum" and current_style != "kvantum":
+ logger.warning(
+ "Qt style '%s' is not available in this PyQt5 build; applying Friture dark palette",
+ style_override,
+ )
+ else:
+ logger.info("Applying Friture dark palette")
+
+ dark = QPalette()
+ dark.setColor(QPalette.Window, QColor(20, 20, 21))
+ dark.setColor(QPalette.WindowText, QColor(240, 240, 240))
+ dark.setColor(QPalette.Base, QColor(12, 12, 13))
+ dark.setColor(QPalette.AlternateBase, QColor(20, 20, 21))
+ dark.setColor(QPalette.ToolTipBase, QColor(240, 240, 240))
+ dark.setColor(QPalette.ToolTipText, QColor(12, 12, 13))
+ dark.setColor(QPalette.Text, QColor(240, 240, 240))
+ dark.setColor(QPalette.Button, QColor(28, 28, 29))
+ dark.setColor(QPalette.ButtonText, QColor(240, 240, 240))
+ dark.setColor(QPalette.BrightText, QColor(255, 80, 80))
+ dark.setColor(QPalette.Link, QColor(80, 160, 255))
+ dark.setColor(QPalette.Highlight, QColor(42, 130, 218))
+ dark.setColor(QPalette.HighlightedText, QColor(240, 240, 240))
+ app.setPalette(dark)
+
+
class Friture(QMainWindow, ):
def __init__(self):
@@ -140,9 +180,8 @@ def __init__(self):
# Initialize the audio data ring buffer
self.audiobuffer = AudioBuffer()
- # Initialize the audio backend
- # signal containing new data from the audio callback thread, processed as numpy array
- AudioBackend().new_data_available.connect(self.audiobuffer.handle_new_data)
+ self.audio_ingest = get_audio_ingest()
+ self.audio_ingest.new_data_available.connect(self.audiobuffer.handle_new_data)
self.player = Player(self)
self.audiobuffer.new_data_available.connect(self.player.handle_new_data)
@@ -157,8 +196,19 @@ def __init__(self):
self._main_window_view_model = MainWindowViewModel(self.qml_engine)
+ self.global_calibration = GlobalCalibrationService(self)
+
self.about_dialog = About_Dialog(self, self.slow_timer)
- self.settings_dialog = Settings_Dialog(self, self._main_window_view_model.toolbar_view_model)
+ self.settings_dialog = Settings_Dialog(
+ self,
+ self._main_window_view_model.toolbar_view_model,
+ catalog=self.audio_ingest,
+ global_calibration=self.global_calibration,
+ raw_rms_provider=lambda: calibration_raw_rms_db(
+ self.audiobuffer, meter=self.level_widget._meter
+ ),
+ )
+ require_input_devices(self, self.audio_ingest)
self.quick_view = QQuickView(self.qml_engine, None)
self.quick_view.setResizeMode(QQuickView.SizeRootObjectToView)
@@ -176,7 +226,11 @@ def __init__(self):
self.main_tile_layout = self.quick_view.findChild(QObject, "main_tile_layout")
assert self.main_tile_layout is not None, "Main tile layout not found in CentralWidget.qml"
- self.level_widget = Levels_Widget(self, self._main_window_view_model.level_view_model)
+ self.level_widget = Levels_Widget(
+ self,
+ self._main_window_view_model.level_view_model,
+ self.global_calibration,
+ )
self.level_widget.set_buffer(self.audiobuffer)
self.audiobuffer.new_data_available.connect(self.level_widget.handle_new_data)
@@ -187,7 +241,7 @@ def __init__(self):
# timer ticks
self.display_timer.timeout.connect(self.dockmanager.canvasUpdate)
self.display_timer.timeout.connect(self.level_widget.canvasUpdate)
- self.display_timer.timeout.connect(AudioBackend().fetchAudioData)
+ self.display_timer.timeout.connect(self.audio_ingest.fetchAudioData)
# toolbar clicks
self._main_window_view_model.toolbar_view_model.recording_clicked.connect(self.timer_toggle)
@@ -237,7 +291,7 @@ def about_called(self):
# event handler
def closeEvent(self, event):
- AudioBackend().close()
+ self.audio_ingest.close()
self.saveAppState()
event.accept()
@@ -323,14 +377,14 @@ def timer_toggle(self):
self.display_timer.stop()
self._main_window_view_model.toolbar_view_model.recording = False
self.playback_widget.stop_recording()
- AudioBackend().pause()
+ self.audio_ingest.pause()
self.dockmanager.pause()
else:
self.logger.info("Timer start")
self.display_timer.start()
self._main_window_view_model.toolbar_view_model.recording = True
self.playback_widget.start_recording()
- AudioBackend().restart()
+ self.audio_ingest.restart()
self.dockmanager.restart()
# slot
@@ -340,7 +394,7 @@ def timer_changed(self, recording: bool):
self.display_timer.stop()
self._main_window_view_model.toolbar_view_model.recording = False
self.playback_widget.stop_recording()
- AudioBackend().pause()
+ self.audio_ingest.pause()
self.dockmanager.pause()
if recording and not self.display_timer.isActive():
@@ -348,7 +402,7 @@ def timer_changed(self, recording: bool):
self.display_timer.start()
self._main_window_view_model.toolbar_view_model.recording = True
self.playback_widget.start_recording()
- AudioBackend().restart()
+ self.audio_ingest.restart()
self.dockmanager.restart()
def qt_message_handler(mode, context, message):
@@ -399,7 +453,7 @@ def main():
parser.add_argument(
"--no-splash",
action="store_true",
- help="Disable the splash screen on startup")
+ help="Disable the splash screen for this launch (overrides Settings)")
program_arguments, remaining_arguments = parser.parse_known_args()
remaining_arguments.insert(0, sys.argv[0])
@@ -476,6 +530,7 @@ def main():
if platform.system() == "Linux":
if "PIPEWIRE_ALSA" not in os.environ:
os.environ['PIPEWIRE_ALSA'] = '{ application.name = "Friture" }'
+ _linux_apply_dark_palette(app, logger)
# Set the style for Qt Quick Controls
# We choose the Fusion style as it is a desktop-oriented style
@@ -484,7 +539,9 @@ def main():
os.environ["QT_QUICK_CONTROLS_STYLE"] = "Fusion"
# Splash screen
- if not program_arguments.no_splash:
+ show_splash = splash_enabled() and not program_arguments.no_splash
+ splash = None
+ if show_splash:
pixmap = QPixmap(":/images/splash.png")
splash = QSplashScreen(pixmap)
splash.show()
@@ -493,7 +550,7 @@ def main():
window = Friture()
window.show()
- if not program_arguments.no_splash:
+ if splash is not None:
splash.hide()
profile = "no" # "python" or "kcachegrind" or anything else to disable
diff --git a/friture/audio_ingest.py b/friture/audio_ingest.py
new file mode 100644
index 00000000..64f4d89a
--- /dev/null
+++ b/friture/audio_ingest.py
@@ -0,0 +1,130 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+
+# Copyright (C) 2024 Celeste Sinéad
+
+"""Audio ingest seam: capture adapters push frames into the app."""
+
+from __future__ import annotations
+
+import logging
+from typing import Optional
+
+import numpy as np
+from numpy import ndarray
+from PyQt5 import QtCore
+
+SAMPLING_RATE = 48000
+FRAMES_PER_BUFFER = 512
+
+__ingest_instance: Optional[QtCore.QObject] = None
+
+
+class TestAudioIngest(QtCore.QObject):
+ """Synthetic capture for tests — emits sine frames on fetchAudioData."""
+
+ underflow = QtCore.pyqtSignal()
+ new_data_available = QtCore.pyqtSignal(ndarray, float, bool)
+
+ def __init__(
+ self,
+ frequency_hz: float = 440.0,
+ amplitude: float = 0.5,
+ frames_per_buffer: int = FRAMES_PER_BUFFER,
+ ) -> None:
+ super().__init__()
+ self.frequency_hz = frequency_hz
+ self.amplitude = amplitude
+ self.frames_per_buffer = frames_per_buffer
+ self._sample_index = 0
+ self.chunk_number = 0
+ self.xruns = 0
+ self.duo_input = False
+
+ def fetchAudioData(self) -> None:
+ time = (
+ np.arange(self.frames_per_buffer, dtype=np.float64) + self._sample_index
+ ) / SAMPLING_RATE
+ samples = self.amplitude * np.sin(2 * np.pi * self.frequency_hz * time)
+ floatdata = samples.reshape(1, -1)
+ stream_time = self._sample_index / SAMPLING_RATE
+ self._sample_index += self.frames_per_buffer
+ self.chunk_number += 1
+ self.new_data_available.emit(floatdata, stream_time, False)
+
+ def close(self) -> None:
+ pass
+
+ def pause(self) -> None:
+ pass
+
+ def restart(self) -> None:
+ self._sample_index = 0
+
+ def get_stream_time(self) -> float:
+ return self._sample_index / SAMPLING_RATE
+
+ def get_readable_devices_list(self) -> list[str]:
+ return ["Test Input (1 channels) (test)"]
+
+ def get_readable_current_channels(self) -> list[str]:
+ return ["0"]
+
+ def get_readable_current_device(self) -> int:
+ return 0
+
+ def get_current_first_channel(self) -> int:
+ return 0
+
+ def get_current_second_channel(self) -> int:
+ return 0
+
+ def select_input_device(self, index: int) -> tuple[bool, int]:
+ return True, index
+
+ def select_first_channel(self, index: int) -> tuple[bool, int]:
+ return True, index
+
+ def select_second_channel(self, index: int) -> tuple[bool, int]:
+ return True, index
+
+ def set_single_input(self) -> None:
+ self.duo_input = False
+
+ def set_duo_input(self) -> None:
+ self.duo_input = True
+
+
+def create_test_ingest(**kwargs) -> TestAudioIngest:
+ return TestAudioIngest(**kwargs)
+
+
+def create_portaudio_ingest() -> QtCore.QObject:
+ from friture.portaudio_ingest import PortAudioIngest
+
+ return PortAudioIngest()
+
+
+def get_audio_ingest() -> QtCore.QObject:
+ global __ingest_instance
+ if __ingest_instance is None:
+ __ingest_instance = create_portaudio_ingest()
+ return __ingest_instance
+
+
+def set_audio_ingest(ingest: Optional[QtCore.QObject]) -> None:
+ global __ingest_instance
+ __ingest_instance = ingest
+
+
+def reset_audio_ingest() -> None:
+ global __ingest_instance
+ if __ingest_instance is not None:
+ close = getattr(__ingest_instance, "close", None)
+ if close is not None:
+ close()
+ __ingest_instance = None
+
+
+def get_audio_ingest_logger() -> logging.Logger:
+ return logging.getLogger(__name__)
diff --git a/friture/audiobackend.py b/friture/audiobackend.py
index 4f6faf22..80247765 100644
--- a/friture/audiobackend.py
+++ b/friture/audiobackend.py
@@ -3,525 +3,10 @@
# Copyright (C) 2009 Timothée Lecomte
-# This file is part of Friture.
-#
-# Friture is free software: you can redistribute it and/or modify
-# it under the terms of the GNU General Public License version 3 as published by
-# the Free Software Foundation.
-#
-# Friture is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-# GNU General Public License for more details.
-#
-# You should have received a copy of the GNU General Public License
-# along with Friture. If not, see .
+# Backward-compatible entry to the audio ingest seam.
-import logging
-import math
-
-from PyQt5 import QtCore
-import sounddevice
-import rtmixer
-from numpy import ndarray, vstack, int8, int16, float64, float32, frombuffer, concatenate
-import numpy as np
-
-# the sample rate below should be dynamic, taken from PyAudio/PortAudio
-SAMPLING_RATE = 48000
-FRAMES_PER_BUFFER = 512
-
-__audiobackendInstance = None
-
-# python-sounddevice (bindings to PortAudio)
-# > no device friendly name
-# > suffer from PortAudio bugs
-# > uses old PortAudio binaries
-# > sounddevice provides nice Python bindngs
-# > rtmixer provides nice C ringbuffer on top of sounddevice
-
-# rtaudio
-# > better maintained than PortAudio
-# > no device friendly name
-# > no ios/android support
-# > no nice Python bindings
-
-# qtmultimedia
-# > shipped with Qt5
-# > no device friendly name
-# > supports iOS and android
-# > opaque
-
-# python-soundcard
-# > not a lot of devs / users
-# > no android support
-# > provides device ids and friendly name
-# > doc, features are lacking
+from friture.audio_ingest import FRAMES_PER_BUFFER, SAMPLING_RATE, get_audio_ingest
def AudioBackend():
- global __audiobackendInstance
- if __audiobackendInstance is None:
- __audiobackendInstance = __AudioBackend()
- return __audiobackendInstance
-
-
-class __AudioBackend(QtCore.QObject):
-
- underflow = QtCore.pyqtSignal()
- new_data_available = QtCore.pyqtSignal(ndarray, float, bool)
-
- def __init__(self):
- QtCore.QObject.__init__(self)
-
- self.logger = logging.getLogger(__name__)
-
- self.duo_input = False
-
- self.logger.info("Initializing audio backend")
-
- # look for devices
- self.input_devices = self.get_input_devices()
- self.output_devices = self.get_output_devices()
-
- self.logger.info(f"Found {len(self.input_devices)} input devices and {len(self.output_devices)} output devices")
-
- self.device = None
- self.first_channel = None
- self.second_channel = None
-
- self.stream = None
- self.ringBuffer = None
- self.action = None
- self.nchannels_max = 0
-
- # we will try to open all the input devices until one
- # works, starting by the default input device
- for device in self.input_devices:
- try:
- (self.stream, self.ringBuffer, self.action, self.nchannels_max) = self.open_stream(device)
- self.stream.start()
- self.device = device
- self.logger.info("Success")
- break
- except Exception:
- self.logger.exception("Failed to open stream")
-
- if self.device is not None:
- self.first_channel = 0
- nchannels = self.get_current_device_nchannels()
- if nchannels == 1:
- self.second_channel = 0
- else:
- self.second_channel = 1
-
- # counter for the number of input buffer overflows
- self.xruns = 0
-
- self.chunk_number = 0
-
- self.devices_with_timing_errors = []
-
- def close(self):
- if self.stream is not None:
- self.stream.stop()
- self.stream = None
-
- # method
- def get_readable_devices_list(self):
- input_devices = self.get_input_devices()
-
- raw_devices = sounddevice.query_devices()
-
- try:
- default_input_device = sounddevice.query_devices(kind='input')
- default_input_device['index'] = raw_devices.index(default_input_device)
- except sounddevice.PortAudioError:
- self.logger.exception("Failed to query the default input device")
- default_input_device = None
-
- devices_list = []
- for device in input_devices:
- api = sounddevice.query_hostapis(device['hostapi'])['name']
-
- if default_input_device is not None and device['index'] == default_input_device['index']:
- extra_info = ' (default)'
- else:
- extra_info = ''
-
- nchannels = device['max_input_channels']
-
- desc = "%s (%d channels) (%s) %s" % (device['name'], nchannels, api, extra_info)
-
- devices_list += [desc]
-
- return devices_list
-
- # method
- def get_readable_output_devices_list(self):
- output_devices = self.get_output_devices()
-
- raw_devices = sounddevice.query_devices()
- default_output_device = sounddevice.query_devices(kind='output')
- default_output_device['index'] = raw_devices.index(default_output_device)
-
- devices_list = []
- for device in output_devices:
- api = sounddevice.query_hostapis(device['hostapi'])['name']
-
- if default_output_device is not None and device['index'] == default_output_device['index']:
- extra_info = ' (default)'
- else:
- extra_info = ''
-
- nchannels = device['max_output_channels']
-
- desc = "%s (%d channels) (%s) %s" % (device['name'], nchannels, api, extra_info)
-
- devices_list += [desc]
-
- return devices_list
-
- # method
- def get_default_input_device(self):
- try:
- index = sounddevice.default.device[0]
- except IOError:
- index = None
-
- return index
-
- # method
- def get_default_output_device(self):
- try:
- index = sounddevice.default.device[1]
- except IOError:
- index = None
-
- return index
-
- # method
- # returns a list of input devices index, starting with the system default
- def get_input_devices(self):
- devices = sounddevice.query_devices()
-
- # early exit if there is no input device. Otherwise query_devices(kind='input') fails
- input_devices = [device for device in devices if device['max_input_channels'] > 0]
-
- if len(input_devices) == 0:
- return []
-
- try:
- default_input_device = sounddevice.query_devices(kind='input')
- except sounddevice.PortAudioError:
- self.logger.exception("Failed to query the default input device")
- default_input_device = None
-
- input_devices = []
- if default_input_device is not None:
- # start by the default input device
- default_input_device['index'] = devices.index(default_input_device)
- input_devices += [default_input_device]
-
- for device in devices:
- # select only the input devices by looking at the number of input channels
- if device['max_input_channels'] > 0:
- device['index'] = devices.index(device)
- # default input device has already been inserted
- if default_input_device is not None and device['index'] != default_input_device['index']:
- input_devices += [device]
-
- return input_devices
-
- # method
- # returns a list of output devices index, starting with the system default
- def get_output_devices(self):
- devices = sounddevice.query_devices()
-
- default_output_device = sounddevice.query_devices(kind='output')
-
- output_devices = []
- if default_output_device is not None:
- # start by the default input device
- default_output_device['index'] = devices.index(default_output_device)
- output_devices += [default_output_device]
-
- for device in devices:
- # select only the output devices by looking at the number of output channels
- if device['max_output_channels'] > 0:
- device['index'] = devices.index(device)
- # default output device has already been inserted
- if default_output_device is not None and device['index'] != default_output_device['index']:
- output_devices += [device]
-
- return output_devices
-
- # method.
- # The index parameter is the index in the self.input_devices list of devices !
- # The return parameter is also an index in the same list.
- def select_input_device(self, index):
- device = self.input_devices[index]
-
- # save current stream in case we need to restore it
- previous_stream = self.stream
- previous_ringBuffer = self.ringBuffer
- previous_action = self.action
- previous_nchannels_max = self.nchannels_max
- previous_device = self.device
-
- self.logger.info("Trying to open input device #%d", index)
-
- try:
- (self.stream, self.ringBuffer, self.action, self.nchannels_max) = self.open_stream(device)
- self.device = device
- self.stream.start()
- self.stream_start_time = self.stream.time
- self.stream_read_index = 0
- success = True
- except Exception:
- self.logger.exception("Failed to open input device")
- success = False
- if self.stream is not None:
- self.stream.stop()
- # restore previous stream
- self.stream = previous_stream
- self.ringBuffer = previous_ringBuffer
- self.action = previous_action
- self.nchannels_max = previous_nchannels_max
- self.device = previous_device
-
- if success:
- self.logger.info("Success")
-
- if previous_stream is not None:
- previous_stream.stop()
-
- self.first_channel = 0
- nchannels = self.device['max_input_channels']
- if nchannels == 1:
- self.second_channel = 0
- else:
- self.second_channel = 1
-
- return success, self.input_devices.index(self.device)
-
- # method
- def select_first_channel(self, index):
- self.first_channel = index
- success = True
- return success, self.first_channel
-
- # method
- def select_second_channel(self, index):
- self.second_channel = index
- success = True
- return success, self.second_channel
-
- # method
- def open_stream(self, device):
- self.log_supported_input_formats(device)
-
- self.logger.info("Opening the stream for device '%s'", device['name'])
-
- # by default we open the device stream with all the channels
- # (interleaved in the data buffer)
- stream = rtmixer.Recorder(
- device=device['index'],
- channels=device['max_input_channels'],
- blocksize=FRAMES_PER_BUFFER,
- # latency=latency,
- samplerate=SAMPLING_RATE)
-
- sampleSize = 4 # the sample size in bytes (float32)
- nchannels_max = device['max_input_channels'] # the number of channels that we record
- elementSize = nchannels_max * sampleSize
-
- # arbitrary size to avoid overflows without using too much memory
- ringbufferSeconds = 3.
-
- # The number of elements in the buffer (must be a power of 2)
- ringbufferSize = 2**int(math.log2(ringbufferSeconds * SAMPLING_RATE))
-
- ringBuffer = rtmixer.RingBuffer(elementSize, ringbufferSize)
-
- # action can be used to read the count of input overflows
- action = stream.record_ringbuffer(ringBuffer)
-
- lat_ms = 1000 * stream.latency
- self.logger.info("Device claims %d ms latency", lat_ms)
-
- return (stream, ringBuffer, action, nchannels_max)
-
- def log_supported_input_formats(self, device):
- samplerates = [22050, 44100, 48000, 96000]
- dtypes = [float32, int16, int8]
- supported_formats = []
- for samplerate in samplerates:
- for dtype in dtypes:
- try:
- sounddevice.check_input_settings(
- device=device['index'],
- channels=device['max_input_channels'],
- dtype=dtype,
- extra_settings=None,
- samplerate=samplerate)
- supported_formats += [f"{samplerate} Hz, {np.dtype(dtype).name}"]
- except Exception:
- pass # check_input_settings throws when the format is not supported
-
- api = sounddevice.query_hostapis(device['hostapi'])['name']
- self.logger.info(f"Supported formats for '{device['name']}' on '{api}': {supported_formats}")
-
- # method
- def open_output_stream(self, device, callback):
- # by default we open the device stream with all the channels
- # (interleaved in the data buffer)
- stream = sounddevice.OutputStream(
- samplerate=SAMPLING_RATE,
- blocksize=FRAMES_PER_BUFFER,
- device=device['index'],
- channels=device['max_output_channels'],
- dtype=int16,
- callback=callback)
-
- return stream
-
- def is_output_format_supported(self, device, output_format):
- # raise sounddevice.PortAudioError if the format is not supported
- # the exception message contains the details, such as an invalid sample rate
- sounddevice.check_output_settings(
- device=device['index'],
- channels=device['max_output_channels'],
- dtype=output_format,
- samplerate=SAMPLING_RATE)
-
- # method
- # return the index of the current input device in the input devices list
- # (not the same as the PortAudio index, since the latter is the index
- # in the list of *all* devices, not only input ones)
- def get_readable_current_device(self):
- return self.input_devices.index(self.device)
-
- # method
- def get_readable_current_channels(self):
- nchannels = self.device['max_input_channels']
-
- if nchannels == 2:
- channels = ['L', 'R']
- else:
- channels = []
- for channel in range(0, nchannels):
- channels += ["%d" % channel]
-
- return channels
-
- # method
- def get_current_first_channel(self):
- return self.first_channel
-
- # method
- def get_current_second_channel(self):
- return self.second_channel
-
- # method
- def get_current_device_nchannels(self):
- return self.device['max_input_channels']
-
- def get_device_outputchannels_count(self, device):
- return device['max_output_channels']
-
- def fetchAudioData(self):
- if self.action is None or self.ringBuffer is None:
- return
-
- while self.ringBuffer.read_available >= FRAMES_PER_BUFFER:
- read, buf1, buf2 = self.ringBuffer.get_read_buffers(FRAMES_PER_BUFFER)
- assert read == FRAMES_PER_BUFFER
-
- stream_time = self.get_stream_time()
-
- buffer1 = frombuffer(buf1, dtype='float32')
- buffer2 = frombuffer(buf2, dtype='float32')
- buffer = concatenate((buffer1, buffer2)).astype(float64)
- buffer.shape = -1, self.nchannels_max
- self.ringBuffer.advance_read_index(FRAMES_PER_BUFFER)
-
- # ideally we would use the exact time of the samples retrieved from the ring buffer,
- # but rtmixer does not provide it
- self.stream_read_index += read
- stream_read_time = self.stream_start_time + self.stream_read_index / SAMPLING_RATE
-
- # when starting a stream, it seems PortAudio gives us some data that is already in the buffer
- # so the stream start time is actually older
- # so we compensate here
- if stream_read_time > stream_time and self.stream_read_index < 100000:
- delta_seconds = stream_read_time - stream_time
- self.stream_start_time -= delta_seconds
-
- if stream_read_time < stream_time - 100 * FRAMES_PER_BUFFER / SAMPLING_RATE:
- self.logger.warning("Ringbuffer lagging behind: ringbuffer time = %f, stream time = %f", stream_read_time, stream_time)
-
- channel = self.get_current_first_channel()
- if self.duo_input:
- channel_2 = self.get_current_second_channel()
-
- floatdata1 = buffer[:, channel]
-
- if self.duo_input:
- floatdata2 = buffer[:, channel_2]
- floatdata = vstack((floatdata1, floatdata2))
- else:
- floatdata = floatdata1
- floatdata.shape = (1, floatdata.size)
-
- input_overflows = self.action.stats.input_overflows
- input_overflow = input_overflows > self.xruns
- if input_overflow:
- self.xruns = input_overflows
- self.logger.info("Stream overflow!")
- self.underflow.emit()
-
- self.new_data_available.emit(floatdata, stream_read_time, input_overflow)
-
- self.chunk_number += 1
-
- def set_single_input(self):
- self.duo_input = False
-
- def set_duo_input(self):
- self.duo_input = True
-
- def get_stream_time(self) -> float:
- """The current stream time in seconds.
-
- The time values are monotonically increasing and have
- unspecified origin.
-
- This provides valid time values for the entire life of the
- stream, from when the stream is opened until it is closed.
- Starting and stopping the stream does not affect the passage of
- time as provided here.
-
- This time may be used for synchronizing other events to the
- audio stream.
- """
-
- if self.stream is None:
- return 0
-
- try:
- return self.stream.time
- except (sounddevice.PortAudioError, OSError):
- if self.stream.device not in self.devices_with_timing_errors:
- self.devices_with_timing_errors.append(self.stream.device)
- self.logger.exception("Failed to read stream time")
- return 0
-
- def pause(self):
- if self.stream is not None:
- self.stream.stop()
-
- def restart(self):
- if self.stream is not None:
- self.stream.start()
- self.stream_start_time = self.stream.time
- self.stream_read_index = 0
+ return get_audio_ingest()
diff --git a/friture/calibrated_display_range.py b/friture/calibrated_display_range.py
new file mode 100644
index 00000000..75044f6c
--- /dev/null
+++ b/friture/calibrated_display_range.py
@@ -0,0 +1,49 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+
+# Copyright (C) 2024 Celeste Sinéad
+
+"""Wire global scalar calibration to frequency-widget display ranges."""
+
+from __future__ import annotations
+
+from friture.global_frequency_calibration import calibrated_spec_range
+from friture.level_calibration import find_global_calibration
+
+
+class CalibratedDisplayRangeMixin:
+ """Keep user spec min/max in base_*; shift plot range by global offset."""
+
+ _calibration_owner = None
+ _base_spec_min: float = 0.0
+ _base_spec_max: float = 0.0
+
+ def init_calibrated_display_range(
+ self, owner, base_min: float, base_max: float
+ ) -> None:
+ self._calibration_owner = owner
+ self._base_spec_min = base_min
+ self._base_spec_max = base_max
+ service = find_global_calibration(owner)
+ if service is not None:
+ service.changed.connect(self._sync_calibrated_display_range)
+ self._sync_calibrated_display_range()
+
+ def set_base_spec_min(self, value: float) -> None:
+ self._base_spec_min = value
+ self._sync_calibrated_display_range()
+
+ def set_base_spec_max(self, value: float) -> None:
+ self._base_spec_max = value
+ self._sync_calibrated_display_range()
+
+ def _sync_calibrated_display_range(self) -> None:
+ self.spec_min, self.spec_max = calibrated_spec_range(
+ self._base_spec_min,
+ self._base_spec_max,
+ self._calibration_owner,
+ )
+ self._apply_calibrated_spec_range(self.spec_min, self.spec_max)
+
+ def _apply_calibrated_spec_range(self, spec_min: float, spec_max: float) -> None:
+ raise NotImplementedError
diff --git a/friture/calibration_override.py b/friture/calibration_override.py
new file mode 100644
index 00000000..3eaedebd
--- /dev/null
+++ b/friture/calibration_override.py
@@ -0,0 +1,96 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+
+# Copyright (C) 2024 Celeste Sinéad
+
+"""Mixin for widgets that support global calibration with optional local override."""
+
+from __future__ import annotations
+
+from friture.level_calibration import (
+ LevelCalibration,
+ find_global_calibration,
+ read_settings_float,
+ resolve_calibration,
+ write_settings_float,
+)
+
+
+class CalibrationOverrideMixin:
+ def init_calibration_override(self, owner) -> None:
+ self._global_calibration = find_global_calibration(owner)
+ self.use_global_calibration = True
+ self.local_calibration = LevelCalibration()
+ if self._global_calibration is not None:
+ self._global_calibration.changed.connect(self._global_calibration_changed)
+
+ def effective_calibration(self) -> LevelCalibration:
+ global_cal = (
+ self._global_calibration.calibration
+ if self._global_calibration is not None
+ else LevelCalibration()
+ )
+ return resolve_calibration(
+ global_cal,
+ self.local_calibration,
+ self.use_global_calibration,
+ )
+
+ def set_use_global_calibration(self, use_global: bool) -> None:
+ if self.use_global_calibration != use_global:
+ self.use_global_calibration = use_global
+ self.on_effective_calibration_changed()
+
+ def set_local_calibration_offset(self, offset_db: float) -> None:
+ self.local_calibration.offset_db = offset_db
+ if not self.use_global_calibration:
+ self.on_effective_calibration_changed()
+
+ def set_local_unit_label(self, unit_label: str) -> None:
+ self.local_calibration.unit_label = unit_label
+ if not self.use_global_calibration:
+ self.on_effective_calibration_changed()
+
+ def set_local_reference_note(self, note: str) -> None:
+ self.local_calibration.reference_note = note
+
+ def calibrate_local_to_target(self, raw_rms_db: float, target_db: float) -> None:
+ from friture.level_calibration import calibration_offset_for_target
+
+ self.local_calibration.offset_db = calibration_offset_for_target(
+ raw_rms_db, target_db
+ )
+ if not self.use_global_calibration:
+ self.on_effective_calibration_changed()
+
+ def _global_calibration_changed(self) -> None:
+ if self.use_global_calibration:
+ self.on_effective_calibration_changed()
+
+ def on_effective_calibration_changed(self) -> None:
+ """Subclasses refresh labels/plots when effective calibration changes."""
+
+ def restore_calibration_override_state(self, settings) -> None:
+ if settings.contains("useGlobalCalibration"):
+ self.use_global_calibration = settings.value(
+ "useGlobalCalibration", True, type=bool
+ )
+ elif settings.contains("offsetDb"):
+ offset = read_settings_float(settings, "offsetDb", 0.0)
+ self.use_global_calibration = offset == 0.0
+
+ self.local_calibration.offset_db = read_settings_float(
+ settings, "offsetDb", self.local_calibration.offset_db
+ )
+ self.local_calibration.unit_label = settings.value(
+ "unitLabel", self.local_calibration.unit_label, type=str
+ )
+ self.local_calibration.reference_note = settings.value(
+ "referenceNote", self.local_calibration.reference_note, type=str
+ )
+
+ def save_calibration_override_state(self, settings) -> None:
+ settings.setValue("useGlobalCalibration", self.use_global_calibration)
+ write_settings_float(settings, "offsetDb", self.local_calibration.offset_db)
+ settings.setValue("unitLabel", self.local_calibration.unit_label)
+ settings.setValue("referenceNote", self.local_calibration.reference_note)
diff --git a/friture/calibration_settings_panel.py b/friture/calibration_settings_panel.py
new file mode 100644
index 00000000..8b4b4def
--- /dev/null
+++ b/friture/calibration_settings_panel.py
@@ -0,0 +1,100 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+
+# Copyright (C) 2024 Celeste Sinéad
+
+"""Shared calibration form rows for settings and dock dialogs."""
+
+from __future__ import annotations
+
+from PyQt5 import QtWidgets
+
+from friture.level_calibration import DEFAULT_OFFSET_DB, DEFAULT_UNIT_LABEL, UNIT_PRESETS
+
+
+class CalibrationFormRows:
+ def __init__(
+ self,
+ form_layout: QtWidgets.QFormLayout,
+ *,
+ include_use_global: bool = False,
+ include_calibrate: bool = True,
+ ) -> None:
+ self._fields: list[QtWidgets.QWidget] = []
+
+ if include_use_global:
+ self.checkBox_use_global = QtWidgets.QCheckBox("Use global calibration")
+ self.checkBox_use_global.setChecked(True)
+ form_layout.addRow(self.checkBox_use_global)
+ else:
+ self.checkBox_use_global = None
+
+ self.doubleSpinBox_offset = QtWidgets.QDoubleSpinBox()
+ self.doubleSpinBox_offset.setDecimals(1)
+ self.doubleSpinBox_offset.setRange(-500.0, 500.0)
+ self.doubleSpinBox_offset.setValue(DEFAULT_OFFSET_DB)
+ self.doubleSpinBox_offset.setSuffix(" dB")
+
+ self.comboBox_unit = QtWidgets.QComboBox()
+ for unit in UNIT_PRESETS:
+ self.comboBox_unit.addItem(unit)
+ self.comboBox_unit.setCurrentText(DEFAULT_UNIT_LABEL)
+
+ self.lineEdit_reference = QtWidgets.QLineEdit()
+ self.lineEdit_reference.setPlaceholderText("Optional calibration note")
+
+ self.button_calibrate = None
+ if include_calibrate:
+ self.button_calibrate = QtWidgets.QPushButton(
+ "Calibrate from current reading…"
+ )
+
+ form_layout.addRow("Calibration offset:", self.doubleSpinBox_offset)
+ form_layout.addRow("Unit label:", self.comboBox_unit)
+ form_layout.addRow("Reference note:", self.lineEdit_reference)
+ if self.button_calibrate is not None:
+ form_layout.addRow("", self.button_calibrate)
+
+ self._fields = [
+ self.doubleSpinBox_offset,
+ self.comboBox_unit,
+ self.lineEdit_reference,
+ ]
+ if self.button_calibrate is not None:
+ self._fields.append(self.button_calibrate)
+
+ if self.checkBox_use_global is not None:
+ self.checkBox_use_global.toggled.connect(self._sync_enabled_state)
+ self._sync_enabled_state(self.checkBox_use_global.isChecked())
+
+ def _sync_enabled_state(self, use_global: bool) -> None:
+ for field in self._fields:
+ field.setEnabled(not use_global)
+
+ def set_use_global(self, use_global: bool) -> None:
+ if self.checkBox_use_global is not None:
+ self.checkBox_use_global.setChecked(use_global)
+
+ def use_global(self) -> bool:
+ if self.checkBox_use_global is None:
+ return True
+ return self.checkBox_use_global.isChecked()
+
+ def load(self, offset_db: float, unit_label: str, reference_note: str) -> None:
+ self.doubleSpinBox_offset.blockSignals(True)
+ self.comboBox_unit.blockSignals(True)
+ self.lineEdit_reference.blockSignals(True)
+ self.doubleSpinBox_offset.setValue(offset_db)
+ if unit_label in UNIT_PRESETS:
+ self.comboBox_unit.setCurrentText(unit_label)
+ self.lineEdit_reference.setText(reference_note)
+ self.doubleSpinBox_offset.blockSignals(False)
+ self.comboBox_unit.blockSignals(False)
+ self.lineEdit_reference.blockSignals(False)
+
+ def save(self) -> tuple[float, str, str]:
+ return (
+ self.doubleSpinBox_offset.value(),
+ self.comboBox_unit.currentText(),
+ self.lineEdit_reference.text(),
+ )
diff --git a/friture/db_levels_dock.py b/friture/db_levels_dock.py
new file mode 100644
index 00000000..45e434d3
--- /dev/null
+++ b/friture/db_levels_dock.py
@@ -0,0 +1,101 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+
+# Copyright (C) 2024 Celeste Sinéad
+
+"""Dock widget: calibrated peak/RMS dB readout."""
+
+from PyQt5.QtCore import QSettings, QObject
+
+from friture.calibration_override import CalibrationOverrideMixin
+from friture.db_levels_settings import DbLevels_Settings_Dialog
+from friture.freq_weighting import DEFAULT_WEIGHTING, weighting_suffix
+from friture.level_meter import LevelMeterProcessor, calibration_raw_rms_db
+from friture.level_view_model import LevelViewModel
+
+
+class DbLevelsDockWidget(QObject, CalibrationOverrideMixin):
+ def __init__(self, parent) -> None:
+ super().__init__(parent)
+
+ self._parent = parent
+ self.init_calibration_override(parent)
+ self.audiobuffer = None
+ self._level_view_model = LevelViewModel(self)
+ self._meter = LevelMeterProcessor()
+ self._sync_view_model_weighting()
+ self.on_effective_calibration_changed()
+ self.settings_dialog = DbLevels_Settings_Dialog(parent, self)
+
+ def on_effective_calibration_changed(self) -> None:
+ self._level_view_model.unit_label = self.effective_calibration().unit_label
+
+ def set_buffer(self, buffer) -> None:
+ self.audiobuffer = buffer
+
+ def handle_new_data(self, floatdata) -> None:
+ self._meter.handle_new_data(
+ floatdata, self._level_view_model, self.effective_calibration()
+ )
+
+ def canvasUpdate(self) -> None:
+ self._meter.canvas_update(self._level_view_model, self._parent.isVisible())
+
+ def pause(self) -> None:
+ pass
+
+ def restart(self) -> None:
+ pass
+
+ def settings_called(self, checked) -> None:
+ del checked
+ self.settings_dialog.sync_from_widget()
+ self.settings_dialog.show()
+
+ def set_calibration_offset(self, offset_db: float) -> None:
+ self.set_local_calibration_offset(offset_db)
+
+ def set_unit_label(self, unit_label: str) -> None:
+ self.set_local_unit_label(unit_label)
+
+ def set_reference_note(self, note: str) -> None:
+ self.set_local_reference_note(note)
+
+ def set_response_time_s(self, response_time_s: float) -> None:
+ self._meter.set_response_time_s(response_time_s)
+
+ def set_weighting(self, weighting: int) -> None:
+ self._meter.set_weighting(weighting)
+ self._sync_view_model_weighting()
+
+ def _sync_view_model_weighting(self) -> None:
+ self._level_view_model.weighting_suffix = weighting_suffix(self._meter.weighting())
+
+ def calibrate_to_target(self, target_db: float) -> None:
+ raw_rms_db = calibration_raw_rms_db(
+ self.audiobuffer,
+ meter=self._meter,
+ weighting=self._meter.weighting(),
+ )
+ self.calibrate_local_to_target(raw_rms_db, target_db)
+ self._meter.reset_smoothing()
+
+ def saveState(self, settings: QSettings) -> None:
+ self.save_calibration_override_state(settings)
+ settings.setValue("responseTimeS", self._meter.response_time_s)
+ settings.setValue("weighting", self._meter.weighting())
+
+ def restoreState(self, settings: QSettings) -> None:
+ self.settings_dialog.restoreState(settings)
+ self.restore_calibration_override_state(settings)
+ self._meter.set_response_time_s(
+ settings.value("responseTimeS", self._meter.response_time_s, type=float)
+ )
+ self.set_weighting(settings.value("weighting", DEFAULT_WEIGHTING, type=int))
+ self.on_effective_calibration_changed()
+
+ def qml_file_name(self) -> str:
+ return "DbLevelsDock.qml"
+
+ def view_model(self) -> LevelViewModel:
+ return self._level_view_model
diff --git a/friture/db_levels_settings.py b/friture/db_levels_settings.py
new file mode 100644
index 00000000..6db168b1
--- /dev/null
+++ b/friture/db_levels_settings.py
@@ -0,0 +1,142 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+
+# Copyright (C) 2024 Celeste Sinéad
+
+from PyQt5 import QtWidgets
+
+from friture.calibration_settings_panel import CalibrationFormRows
+from friture.freq_weighting import DEFAULT_WEIGHTING, WEIGHTING_NAMES
+from friture.level_calibration import (
+ DEFAULT_OFFSET_DB,
+ DEFAULT_UNIT_LABEL,
+ read_settings_float,
+ unit_label_for_calibration_target,
+ write_settings_float,
+)
+from friture.level_meter import (
+ DEFAULT_RESPONSE_TIME_S,
+ calibration_quiet_message,
+ calibration_raw_rms_db,
+ calibration_signal_too_quiet,
+)
+from friture.settings_dialog_layout import create_form_layout
+
+UNIT_PRESETS = ["dBSPL", "dBu", "dBFS", "dB"]
+
+
+class DbLevels_Settings_Dialog(QtWidgets.QDialog):
+ def __init__(self, parent, widget) -> None:
+ super().__init__(parent)
+
+ self._widget = widget
+ self.setWindowTitle("dB levels settings")
+
+ self.formLayout = create_form_layout(self)
+
+ self.calibration_rows = CalibrationFormRows(
+ self.formLayout, include_use_global=True
+ )
+ self.calibration_rows.checkBox_use_global.toggled.connect(
+ self._widget.set_use_global_calibration
+ )
+ self.calibration_rows.doubleSpinBox_offset.valueChanged.connect(
+ self._widget.set_calibration_offset
+ )
+ self.calibration_rows.comboBox_unit.currentTextChanged.connect(
+ self._widget.set_unit_label
+ )
+ self.calibration_rows.lineEdit_reference.textChanged.connect(
+ self._widget.set_reference_note
+ )
+ self.calibration_rows.button_calibrate.clicked.connect(self._calibrate_from_current)
+
+ self.doubleSpinBox_response = QtWidgets.QDoubleSpinBox(self)
+ self.doubleSpinBox_response.setDecimals(2)
+ self.doubleSpinBox_response.setMinimum(0.05)
+ self.doubleSpinBox_response.setMaximum(5.0)
+ self.doubleSpinBox_response.setSingleStep(0.05)
+ self.doubleSpinBox_response.setValue(DEFAULT_RESPONSE_TIME_S)
+ self.doubleSpinBox_response.setSuffix(" s")
+
+ self.comboBox_weighting = QtWidgets.QComboBox(self)
+ for name in WEIGHTING_NAMES:
+ self.comboBox_weighting.addItem(name)
+ self.comboBox_weighting.setCurrentIndex(DEFAULT_WEIGHTING)
+
+ self.formLayout.addRow("Frequency weighting:", self.comboBox_weighting)
+ self.formLayout.addRow("RMS response time:", self.doubleSpinBox_response)
+
+ self.comboBox_weighting.currentIndexChanged.connect(self._widget.set_weighting)
+ self.doubleSpinBox_response.valueChanged.connect(self._widget.set_response_time_s)
+
+ def sync_from_widget(self) -> None:
+ self.calibration_rows.set_use_global(self._widget.use_global_calibration)
+ self.calibration_rows.load(
+ self._widget.local_calibration.offset_db,
+ self._widget.local_calibration.unit_label,
+ self._widget.local_calibration.reference_note,
+ )
+
+ def _calibrate_from_current(self) -> None:
+ raw_rms_db = calibration_raw_rms_db(
+ self._widget.audiobuffer,
+ meter=self._widget._meter,
+ weighting=self._widget._meter.weighting(),
+ )
+ if calibration_signal_too_quiet(raw_rms_db):
+ cal = self._widget.effective_calibration()
+ QtWidgets.QMessageBox.warning(
+ self,
+ "Calibrate level",
+ calibration_quiet_message(
+ raw_rms_db,
+ offset_db=cal.offset_db,
+ unit_label=cal.unit_label,
+ ),
+ )
+ return
+ target_db, ok = QtWidgets.QInputDialog.getDouble(
+ self,
+ "Calibrate level",
+ f"Raw input is {raw_rms_db:.1f} dBFS.\n"
+ "It should read (dB):",
+ value=94.0,
+ decimals=1,
+ )
+ if ok:
+ unit_label = unit_label_for_calibration_target(
+ self._widget.local_calibration.unit_label, target_db
+ )
+ if unit_label != self._widget.local_calibration.unit_label:
+ self._widget.set_local_unit_label(unit_label)
+ self._widget.calibrate_local_to_target(raw_rms_db, target_db)
+ self.calibration_rows.load(
+ self._widget.local_calibration.offset_db,
+ self._widget.local_calibration.unit_label,
+ self._widget.local_calibration.reference_note,
+ )
+
+ def saveState(self, settings) -> None:
+ settings.setValue("useGlobalCalibration", self.calibration_rows.use_global())
+ offset_db, unit_label, reference_note = self.calibration_rows.save()
+ write_settings_float(settings, "offsetDb", offset_db)
+ settings.setValue("unitLabel", unit_label)
+ settings.setValue("referenceNote", reference_note)
+ settings.setValue("responseTimeS", self.doubleSpinBox_response.value())
+ settings.setValue("weighting", self.comboBox_weighting.currentIndex())
+
+ def restoreState(self, settings) -> None:
+ use_global = settings.value("useGlobalCalibration", True, type=bool)
+ self.calibration_rows.set_use_global(use_global)
+ self.calibration_rows.load(
+ read_settings_float(settings, "offsetDb", DEFAULT_OFFSET_DB),
+ settings.value("unitLabel", DEFAULT_UNIT_LABEL, type=str),
+ settings.value("referenceNote", "", type=str),
+ )
+ self.doubleSpinBox_response.setValue(
+ settings.value("responseTimeS", DEFAULT_RESPONSE_TIME_S, type=float)
+ )
+ self.comboBox_weighting.setCurrentIndex(
+ settings.value("weighting", DEFAULT_WEIGHTING, type=int)
+ )
diff --git a/friture/delay_estimator.py b/friture/delay_estimator.py
index 0f0676e2..aae42e29 100644
--- a/friture/delay_estimator.py
+++ b/friture/delay_estimator.py
@@ -23,6 +23,7 @@
from friture import generated_filters
from friture.delay_estimator_view_model import Delay_Estimator_View_Model
+from friture.settings_dialog_layout import create_form_layout
from .audiobackend import SAMPLING_RATE
from .ringbuffer import RingBuffer
from .signal.decimate import decimate_multiple, decimate_multiple_filtic
@@ -217,7 +218,7 @@ def __init__(self, parent, view_model):
self.setWindowTitle("Delay estimator settings")
- self.form_layout = QtWidgets.QFormLayout(self)
+ self.form_layout = create_form_layout(self)
self.double_spinbox_delayrange = QtWidgets.QDoubleSpinBox(self)
self.double_spinbox_delayrange.setDecimals(1)
@@ -229,8 +230,6 @@ def __init__(self, parent, view_model):
self.form_layout.addRow("Delay range (maximum delay that is reliably estimated):", self.double_spinbox_delayrange)
- self.setLayout(self.form_layout)
-
self.double_spinbox_delayrange.valueChanged.connect(view_model.set_delayrange)
# method
diff --git a/friture/dock.py b/friture/dock.py
index c1dc22e2..9b2bd912 100644
--- a/friture/dock.py
+++ b/friture/dock.py
@@ -27,6 +27,7 @@
from friture.qml_tools import component_raise_if_error, qml_url
from friture.widgetdict import getWidgetById, widgetIds
from friture.controlbar_viewmodel import ControlBarViewModel
+from friture.dock_analysis_widget import DockAnalysisWidget
from typing import Optional, TYPE_CHECKING
if TYPE_CHECKING:
@@ -89,7 +90,7 @@ def __init__(
assert self.audio_widget_container is not None, "Audio widget container not found in Dock.qml"
self.widgetId: Optional[int] = None
- self.audiowidget: Optional[QObject] = None
+ self.audiowidget: Optional[DockAnalysisWidget] = None
self.audio_widget_qml: Optional[QQuickItem] = None
if widgetId is None:
@@ -136,9 +137,11 @@ def widget_select(self, widgetId: int) -> None:
return
if self.audio_widget_qml is not None:
- self.audio_widget_qml.setParentItem(None) # type: ignore
- self.audio_widget_qml.deleteLater()
+ old_qml = self.audio_widget_qml
self.audio_widget_qml = None
+ old_qml.setParentItem(None) # type: ignore
+ old_qml.setParent(None)
+ old_qml.deleteLater()
if self.audiowidget is not None:
settings = QSettings()
@@ -166,28 +169,40 @@ def widget_select(self, widgetId: int) -> None:
initialProperties = {
"fixedFont": QFontDatabase.systemFont(QFontDatabase.FixedFont).family(),
- "viewModel": self.audiowidget.view_model(), # type: ignore
+ "viewModel": self.audiowidget.view_model(),
}
- # audiowidget is duck typed for this:
- self.audiowidget.set_buffer(self.audiobuffer) # type: ignore
- self.audiobuffer.new_data_available.connect(
- self.audiowidget.handle_new_data) # type: ignore
+ self.audiowidget.set_buffer(self.audiobuffer)
+ self.audiobuffer.new_data_available.connect(self.audiowidget.handle_new_data)
if widgetId in self.dockmanager.last_settings:
- self.audiowidget.restoreState( # type: ignore
- self.dockmanager.last_settings[widgetId])
+ self.audiowidget.restoreState(self.dockmanager.last_settings[widgetId])
component = QQmlComponent(self.qml_engine)
- component.loadUrl(qml_url(self.audiowidget.qml_file_name())) # type: ignore
+ component.loadUrl(qml_url(self.audiowidget.qml_file_name()))
component_raise_if_error(component)
component.statusChanged.connect(self.on_status_changed)
qml_context = self.qml_engine.rootContext()
- self.audio_widget_qml = component.createWithInitialProperties(initialProperties, qml_context) # type: ignore
- self.audio_widget_qml.setParent(self.qml_engine) # type: ignore
- self.audio_widget_qml.setParentItem(self.audio_widget_container) # type: ignore
- self.audio_widget_qml.setProperty("anchors.fill", self.audio_widget_container) # type: ignore
+ self.audio_widget_qml = component.createWithInitialProperties(initialProperties, qml_context) # type: ignore
+ component_raise_if_error(component)
+ if self.audio_widget_qml is None:
+ raise Exception("Failed to create QML for widget %s" % widget_descriptor["Name"])
+ self.audio_widget_qml.setParent(self.qml_engine) # type: ignore
+ self.audio_widget_qml.setParentItem(self.audio_widget_container) # type: ignore
+ compact = getattr(self.audiowidget, "compact_in_tile", None)
+ if compact is not None and compact():
+ self.audio_widget_qml.setProperty("anchors.top", self.audio_widget_container) # type: ignore
+ self.audio_widget_qml.setProperty("anchors.right", self.audio_widget_container) # type: ignore
+ self.audio_widget_qml.setProperty("anchors.left", None) # type: ignore
+ self.audio_widget_qml.setProperty("anchors.bottom", None) # type: ignore
+ self.audio_widget_qml.setProperty("anchors.fill", None) # type: ignore
+ else:
+ self.audio_widget_qml.setProperty("anchors.top", None) # type: ignore
+ self.audio_widget_qml.setProperty("anchors.right", None) # type: ignore
+ self.audio_widget_qml.setProperty("anchors.left", None) # type: ignore
+ self.audio_widget_qml.setProperty("anchors.bottom", None) # type: ignore
+ self.audio_widget_qml.setProperty("anchors.fill", self.audio_widget_container) # type: ignore
index = widgetIds().index(widgetId)
self.controlbar_viewmodel.setCurrentIndex(index)
@@ -204,11 +219,15 @@ def canvasUpdate(self):
def pause(self):
if self.audiowidget is not None:
- self.audiowidget.pause()
+ pause = getattr(self.audiowidget, "pause", None)
+ if pause is not None:
+ pause()
def restart(self):
if self.audiowidget is not None:
- self.audiowidget.restart()
+ restart = getattr(self.audiowidget, "restart", None)
+ if restart is not None:
+ restart()
# slot
def settings_slot(self, checked):
diff --git a/friture/dock_analysis_widget.py b/friture/dock_analysis_widget.py
new file mode 100644
index 00000000..eed94adb
--- /dev/null
+++ b/friture/dock_analysis_widget.py
@@ -0,0 +1,82 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+
+# Copyright (C) 2024 Celeste Sinéad
+
+"""Dock analysis widget protocol and shared ingest helpers.
+
+Dock widgets live in ``widgetdict`` and are hosted by ``Dock``. Each widget
+implements this protocol so ``Dock`` can wire audio without duck typing.
+
+Invariants for ``handle_new_data``:
+- ``floatdata`` shape is ``(channels, samples)`` from ``AudioBuffer``.
+- Widgets that read history use ``RingBufferFrameReader`` + ``set_buffer``.
+- Widgets that only need the latest chunk may consume ``floatdata`` directly
+ (levels, octave spectrum).
+- ``canvasUpdate`` runs on the display timer (~25 ms); keep heavy work in
+ ``handle_new_data`` or amortize across frames.
+
+Integration tests should use ``AudioHarness`` + ``wire_dock_analysis_widget``
+to mimic production wiring without starting the full app.
+"""
+
+from __future__ import annotations
+
+from typing import Protocol, runtime_checkable
+
+import numpy as np
+from PyQt5.QtCore import QSettings, QObject
+
+from friture.audiobuffer import AudioBuffer
+
+
+@runtime_checkable
+class DockAnalysisWidget(Protocol):
+ """Public surface ``Dock`` expects from an analysis widget."""
+
+ def set_buffer(self, buffer: AudioBuffer) -> None:
+ """Attach shared ring buffer; reset any read indices."""
+
+ def handle_new_data(self, floatdata: np.ndarray) -> None:
+ """Process new samples already pushed into ``AudioBuffer``."""
+
+ def canvasUpdate(self) -> None:
+ """Refresh QML-facing view models on the display timer."""
+
+ def pause(self) -> None:
+ ...
+
+ def restart(self) -> None:
+ ...
+
+ def settings_called(self, checked: bool) -> None:
+ ...
+
+ def saveState(self, settings: QSettings) -> None:
+ ...
+
+ def restoreState(self, settings: QSettings) -> None:
+ ...
+
+ def qml_file_name(self) -> str:
+ ...
+
+ def view_model(self) -> QObject:
+ ...
+
+
+def is_dock_analysis_widget(widget: object) -> bool:
+ return isinstance(widget, DockAnalysisWidget)
+
+
+def stereo_mode_from_chunk(floatdata: np.ndarray, two_channels: bool) -> bool:
+ """Return whether the UI should show a second channel strip."""
+ if floatdata.shape[0] > 1 and not two_channels:
+ return True
+ if floatdata.shape[0] == 1 and two_channels:
+ return False
+ return two_channels
+
+
+# Widgets not yet migrated to RingBufferFrameReader (issue #3 follow-up):
+# scope, spectrogram, pitch tracker, generator, delay estimator, long levels
diff --git a/friture/exceptionhandler.py b/friture/exceptionhandler.py
index df5b755f..9755c28d 100644
--- a/friture/exceptionhandler.py
+++ b/friture/exceptionhandler.py
@@ -19,6 +19,7 @@
import sys
import logging
+import os
import time
import traceback
import friture
@@ -39,6 +40,7 @@ def fileexcepthook(exception_type, exception_value, traceback_object):
# same as in analyzer.py
logFileName = "friture.log.txt"
logDir = platformdirs.user_log_dir("Friture", "")
+ logFilePath = os.path.join(logDir, logFileName)
notice = """
Oops! Something went wrong!
@@ -47,10 +49,10 @@ def fileexcepthook(exception_type, exception_value, traceback_object):
(this is not guaranteed to work).
Please help us fix it!
\n\n
Please create an issue on https://github.com/tlecomte/friture/issues
- and include the log file named {logFileName} from the following folder:
- {logDir}1
+ and include the log file:
+ {logFilePath}
Error details
""" \
- .format(logFileName = logFileName, logDir = logDir)
+ .format(logFilePath=logFilePath)
msg = notice + timeString + ' (%s)' % versionInfo + '
' + exceptionText.replace("\r\n", "\n").replace("\n", "
").replace(" ", ' ')
diff --git a/friture/freq_weighting.py b/friture/freq_weighting.py
new file mode 100644
index 00000000..95d161aa
--- /dev/null
+++ b/friture/freq_weighting.py
@@ -0,0 +1,117 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+
+# Copyright (C) 2024 Celeste Sinéad
+
+"""IEC-style A/B/C frequency weighting for time-domain level meters."""
+
+from __future__ import annotations
+
+import numpy as np
+
+from friture.audiobackend import SAMPLING_RATE
+from friture_extensions.lfilter import pyx_lfilter_float64_1D
+
+WEIGHTING_NONE = 0
+WEIGHTING_A = 1
+WEIGHTING_B = 2
+WEIGHTING_C = 3
+DEFAULT_WEIGHTING = WEIGHTING_NONE
+
+WEIGHTING_NAMES = ("None", "A", "B", "C")
+
+# Biquad sections (b, a) for 48 kHz, from matched-z A/B/C design (MIT, sound_weighting_filters).
+_WEIGHTING_BIQUADS = {
+ WEIGHTING_A: (
+ (
+ np.array([0.7545506848211173, 0.0, 0.0]),
+ np.array([1.0, -0.4053225490428304, 0.04107159219064441]),
+ ),
+ (
+ np.array([1.0, -2.0, 1.0]),
+ np.array([1.0, -1.8939389909436022, 0.8952272888746267]),
+ ),
+ (
+ np.array([1.0, -2.0, 1.0]),
+ np.array([1.0, -1.9946144592366002, 0.9946217102489288]),
+ ),
+ ),
+ WEIGHTING_B: (
+ (
+ np.array([0.6390744656351163, 0.0, 0.0]),
+ np.array([1.0, -0.2026612745214152, 0.0]),
+ ),
+ (
+ np.array([1.0, -1.0, 0.0]),
+ np.array([1.0, -1.1821287930983142, 0.19850013566712227]),
+ ),
+ (
+ np.array([1.0, -2.0, 1.0]),
+ np.array([1.0, -1.9946144592366002, 0.9946217102489288]),
+ ),
+ ),
+ WEIGHTING_C: (
+ (
+ np.array([0.6377662580605287, 0.0, 0.0]),
+ np.array([1.0, -0.4053225490428304, 0.04107159219064441]),
+ ),
+ (
+ np.array([1.0, -2.0, 1.0]),
+ np.array([1.0, -1.9946144592366002, 0.9946217102489288]),
+ ),
+ ),
+}
+
+
+def weighting_suffix(weighting: int) -> str:
+ if weighting == WEIGHTING_NONE:
+ return ""
+ if 0 <= weighting < len(WEIGHTING_NAMES):
+ return f" ({WEIGHTING_NAMES[weighting]})"
+ return ""
+
+
+def _empty_filter_state() -> list[np.ndarray]:
+ return []
+
+
+def _make_filter_state(weighting: int) -> list[np.ndarray]:
+ sections = _WEIGHTING_BIQUADS.get(weighting, ())
+ return [np.zeros(len(b) - 1) for b, _a in sections]
+
+
+class WeightingFilter:
+ def __init__(self) -> None:
+ self.weighting = DEFAULT_WEIGHTING
+ self._state_ch1 = _empty_filter_state()
+ self._state_ch2 = _empty_filter_state()
+
+ def set_weighting(self, weighting: int) -> None:
+ if weighting not in _WEIGHTING_BIQUADS and weighting != WEIGHTING_NONE:
+ weighting = DEFAULT_WEIGHTING
+ if weighting == self.weighting:
+ return
+ self.weighting = weighting
+ self.reset()
+
+ def reset(self) -> None:
+ if self.weighting == WEIGHTING_NONE:
+ self._state_ch1 = _empty_filter_state()
+ self._state_ch2 = _empty_filter_state()
+ return
+ state = _make_filter_state(self.weighting)
+ self._state_ch1 = [zi.copy() for zi in state]
+ self._state_ch2 = [zi.copy() for zi in state]
+
+ def apply(self, samples: np.ndarray, channel: int) -> np.ndarray:
+ if self.weighting == WEIGHTING_NONE or samples.size == 0:
+ return samples
+
+ if SAMPLING_RATE != 48000:
+ return samples
+
+ state = self._state_ch1 if channel == 1 else self._state_ch2
+ output = np.ascontiguousarray(samples, dtype=np.float64)
+ for index, (b, a) in enumerate(_WEIGHTING_BIQUADS[self.weighting]):
+ output, state[index] = pyx_lfilter_float64_1D(b, a, output, state[index])
+ return output
diff --git a/friture/generator.py b/friture/generator.py
index ff885b55..13206070 100644
--- a/friture/generator.py
+++ b/friture/generator.py
@@ -24,6 +24,7 @@
import numpy as np
import sounddevice
from friture.audiobackend import SAMPLING_RATE, AudioBackend
+from friture.settings_dialog_layout import create_form_layout
from friture.generators.sweep import SweepGenerator, Sweep_Generator_Settings_View_Model
from friture.generators.sine import SineGenerator, Sine_Generator_Settings_View_Model
from friture.generators.burst import BurstGenerator, Burst_Generator_Settings_View_Model
@@ -338,15 +339,13 @@ def __init__(self, parent, devices, device_index):
self.setWindowTitle("Generator settings")
- self.form_layout = QtWidgets.QFormLayout(self)
+ self.form_layout = create_form_layout(self)
self.combobox_output_device = QtWidgets.QComboBox(self)
self.combobox_output_device.setObjectName("comboBox_outputDevice")
self.form_layout.addRow("Select the output device:", self.combobox_output_device)
- self.setLayout(self.form_layout)
-
for device in devices:
self.combobox_output_device.addItem(device)
diff --git a/friture/global_calibration.py b/friture/global_calibration.py
new file mode 100644
index 00000000..8b90d046
--- /dev/null
+++ b/friture/global_calibration.py
@@ -0,0 +1,162 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+
+# Copyright (C) 2024 Celeste Sinéad
+
+"""App-wide input level calibration stored in main settings."""
+
+from __future__ import annotations
+
+import os
+
+import numpy as np
+from PyQt5.QtCore import QObject, QSettings, pyqtSignal
+
+from friture.level_calibration import (
+ DEFAULT_OFFSET_DB,
+ DEFAULT_UNIT_LABEL,
+ LevelCalibration,
+ calibration_offset_for_target,
+ python_float,
+ read_settings_float,
+ write_settings_float,
+)
+from friture.mic_cal_file import MicCalFile, MicCalFileError, load_mic_cal_file
+
+
+class GlobalCalibrationService(QObject):
+ changed = pyqtSignal()
+
+ def __init__(self, parent=None) -> None:
+ super().__init__(parent)
+ self.calibration = LevelCalibration()
+ self.mic_cal: MicCalFile | None = None
+ self.mic_cal_file_path = ""
+
+ def frequency_adjustment_db(self, frequencies_hz) -> "np.ndarray":
+ from friture.global_frequency_calibration import frequency_adjustment_db
+
+ return frequency_adjustment_db(
+ frequencies_hz,
+ offset_db=self.calibration.offset_db,
+ mic_cal=self.mic_cal,
+ )
+
+ def set_mic_cal_file(self, path: str) -> None:
+ path = path.strip()
+ if not path:
+ self.clear_mic_cal_file()
+ return
+
+ absolute_path = os.path.abspath(path)
+ if absolute_path == self.mic_cal_file_path and self.mic_cal is not None:
+ return
+
+ mic_cal = load_mic_cal_file(absolute_path)
+ self.mic_cal = mic_cal
+ self.mic_cal_file_path = mic_cal.source_path
+ if not self.calibration.reference_note.strip():
+ self.calibration.reference_note = mic_cal.summary()
+ self.changed.emit()
+
+ def clear_mic_cal_file(self) -> None:
+ if self.mic_cal is None and not self.mic_cal_file_path:
+ return
+ self.mic_cal = None
+ self.mic_cal_file_path = ""
+ self.changed.emit()
+
+ def try_reload_mic_cal_file(self) -> None:
+ if not self.mic_cal_file_path:
+ return
+ try:
+ self.mic_cal = load_mic_cal_file(self.mic_cal_file_path)
+ except (MicCalFileError, OSError):
+ pass
+
+ def set_offset_db(self, offset_db: float) -> None:
+ offset_db = python_float(offset_db)
+ if self.calibration.offset_db != offset_db:
+ self.calibration.offset_db = offset_db
+ self.changed.emit()
+
+ def set_unit_label(self, unit_label: str) -> None:
+ if self.calibration.unit_label != unit_label:
+ self.calibration.unit_label = unit_label
+ self.changed.emit()
+
+ def set_reference_note(self, note: str) -> None:
+ if self.calibration.reference_note != note:
+ self.calibration.reference_note = note
+ self.changed.emit()
+
+ def calibrate_to_target(self, raw_rms_db: float, target_db: float) -> None:
+ self.set_offset_db(calibration_offset_for_target(raw_rms_db, target_db))
+
+ def saveState(self, settings: QSettings) -> None:
+ write_settings_float(settings, "offsetDb", self.calibration.offset_db)
+ settings.setValue("unitLabel", self.calibration.unit_label)
+ settings.setValue("referenceNote", self.calibration.reference_note)
+ settings.setValue("micCalFilePath", self.mic_cal_file_path)
+ if self.mic_cal is not None:
+ settings.setValue(
+ "micCalFrequencies", self.mic_cal.frequencies_hz.tolist()
+ )
+ settings.setValue(
+ "micCalCorrections", self.mic_cal.corrections_db.tolist()
+ )
+ if self.mic_cal.sensitivity_db is not None:
+ write_settings_float(
+ settings, "micCalSensitivityDb", self.mic_cal.sensitivity_db
+ )
+ else:
+ settings.remove("micCalSensitivityDb")
+ if self.mic_cal.reference_freq_hz is not None:
+ write_settings_float(
+ settings, "micCalReferenceFreqHz", self.mic_cal.reference_freq_hz
+ )
+ else:
+ settings.remove("micCalReferenceFreqHz")
+ else:
+ settings.remove("micCalFrequencies")
+ settings.remove("micCalCorrections")
+ settings.remove("micCalSensitivityDb")
+ settings.remove("micCalReferenceFreqHz")
+
+ def restoreState(self, settings: QSettings) -> None:
+ self.calibration.offset_db = read_settings_float(
+ settings, "offsetDb", DEFAULT_OFFSET_DB
+ )
+ self.calibration.unit_label = settings.value(
+ "unitLabel", DEFAULT_UNIT_LABEL, type=str
+ )
+ self.calibration.reference_note = settings.value(
+ "referenceNote", "", type=str
+ )
+ self.mic_cal_file_path = settings.value("micCalFilePath", "", type=str)
+ self.mic_cal = None
+ if self.mic_cal_file_path:
+ try:
+ self.mic_cal = load_mic_cal_file(self.mic_cal_file_path)
+ except (MicCalFileError, OSError):
+ self.mic_cal = self._mic_cal_from_settings_cache(settings)
+ elif settings.contains("micCalFrequencies"):
+ self.mic_cal = self._mic_cal_from_settings_cache(settings)
+ write_settings_float(settings, "offsetDb", self.calibration.offset_db)
+ self.changed.emit()
+
+ @staticmethod
+ def _mic_cal_from_settings_cache(settings: QSettings) -> MicCalFile | None:
+ frequencies = settings.value("micCalFrequencies", [], type=list)
+ corrections = settings.value("micCalCorrections", [], type=list)
+ if len(frequencies) < 2 or len(corrections) != len(frequencies):
+ return None
+ sensitivity = read_settings_float(settings, "micCalSensitivityDb", float("nan"))
+ reference_freq = read_settings_float(settings, "micCalReferenceFreqHz", float("nan"))
+ return MicCalFile(
+ frequencies_hz=np.asarray(frequencies, dtype=float),
+ corrections_db=np.asarray(corrections, dtype=float),
+ sensitivity_db=None if np.isnan(sensitivity) else sensitivity,
+ reference_freq_hz=None if np.isnan(reference_freq) else reference_freq,
+ source_path=settings.value("micCalFilePath", "", type=str),
+ )
diff --git a/friture/global_frequency_calibration.py b/friture/global_frequency_calibration.py
new file mode 100644
index 00000000..e2b61250
--- /dev/null
+++ b/friture/global_frequency_calibration.py
@@ -0,0 +1,68 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+
+# Copyright (C) 2024 Celeste Sinéad
+
+"""Apply global scalar + mic cal-file correction to frequency-domain dB values."""
+
+from __future__ import annotations
+
+import numpy as np
+from numpy import float64
+
+from friture.level_calibration import find_global_calibration
+from friture.mic_cal_file import MicCalFile
+
+
+def frequency_adjustment_db(
+ frequencies_hz: np.ndarray,
+ *,
+ offset_db: float = 0.0,
+ mic_cal: MicCalFile | None = None,
+) -> np.ndarray:
+ """Return dB values to add to raw spectrum/octave bins for calibrated display."""
+ freqs = np.asarray(frequencies_hz, dtype=float64)
+ adjustment = np.full(freqs.shape, offset_db, dtype=float64)
+ if mic_cal is not None and mic_cal.has_frequency_data:
+ adjustment -= mic_cal.interpolate_db(freqs)
+ return adjustment
+
+
+def scalar_offset_db_for_owner(owner) -> float:
+ service = find_global_calibration(owner)
+ if service is None:
+ return 0.0
+ return float(service.calibration.offset_db)
+
+
+def mic_frequency_adjustment_db_for_owner(
+ owner,
+ frequencies_hz: np.ndarray,
+) -> np.ndarray:
+ """Frequency-dependent mic cal correction only (no scalar level offset)."""
+ service = find_global_calibration(owner)
+ if service is None:
+ return np.zeros(len(frequencies_hz), dtype=float64)
+ return frequency_adjustment_db(
+ frequencies_hz,
+ offset_db=0.0,
+ mic_cal=service.mic_cal,
+ )
+
+
+def calibrated_spec_range(
+ base_min: float, base_max: float, owner
+) -> tuple[float, float]:
+ """Shift user display limits by the global scalar calibration offset."""
+ offset = scalar_offset_db_for_owner(owner)
+ return base_min + offset, base_max + offset
+
+
+def frequency_adjustment_db_for_owner(
+ owner,
+ frequencies_hz: np.ndarray,
+) -> np.ndarray:
+ service = find_global_calibration(owner)
+ if service is None:
+ return np.zeros(len(frequencies_hz), dtype=float64)
+ return service.frequency_adjustment_db(frequencies_hz)
diff --git a/friture/iec.js b/friture/iec.js
index 55a436ad..f942f1c2 100644
--- a/friture/iec.js
+++ b/friture/iec.js
@@ -12,6 +12,54 @@ function dB_to_IEC(dB) {
} else if (dB < -20.0) {
return (dB + 30.0) * 0.02 + 0.3;
} else {
- return (dB + 20.0) * 0.025 + 0.5;
+ return Math.min(1.0, (dB + 20.0) * 0.025 + 0.5);
}
-}
\ No newline at end of file
+}
+
+function normalizeUnitLabel(unitLabel) {
+ if (unitLabel === "dBFS" || unitLabel === "dB") {
+ return "dB FS";
+ }
+ return unitLabel;
+}
+
+function meterDisplayRange(unitLabel) {
+ var normalized = normalizeUnitLabel(unitLabel);
+ if (normalized === "dBSPL") {
+ return { bottom: 40.0, top: 120.0 };
+ }
+ if (normalized === "dBu") {
+ return { bottom: -40.0, top: 20.0 };
+ }
+ return null;
+}
+
+function meterScaleTicks(unitLabel) {
+ var range = meterDisplayRange(unitLabel);
+ if (range === null) {
+ return [0, -3, -6, -10, -20, -30, -40, -50, -60];
+ }
+ var ticks = [];
+ for (var tick = range.top; tick >= range.bottom; tick -= 10.0) {
+ ticks.push(tick);
+ }
+ return ticks;
+}
+
+function level_db_to_meter_fraction(levelDb, unitLabel) {
+ var range = meterDisplayRange(unitLabel);
+ if (range === null) {
+ return dB_to_IEC(Math.min(levelDb, 0.0));
+ }
+ if (levelDb <= range.bottom) {
+ return 0.0;
+ }
+ if (levelDb >= range.top) {
+ return 1.0;
+ }
+ return (levelDb - range.bottom) / (range.top - range.bottom);
+}
+
+function level_db_to_iec(levelDb, unitLabel) {
+ return level_db_to_meter_fraction(levelDb, unitLabel);
+}
diff --git a/friture/iec.py b/friture/iec.py
index ae5fcc10..5fe4fbf4 100644
--- a/friture/iec.py
+++ b/friture/iec.py
@@ -30,5 +30,89 @@ def dB_to_IEC(dB):
return (dB + 40.0) * 0.015 + 0.15
elif dB < -20.0:
return (dB + 30.0) * 0.02 + 0.3
- else: # if dB < 0.0
- return (dB + 20.0) * 0.025 + 0.5
\ No newline at end of file
+ else:
+ return min(1.0, (dB + 20.0) * 0.025 + 0.5)
+
+
+# Meter bar shows readings between bottom and top (calibrated dB values).
+# dB FS keeps the classic IEC scale on raw digital levels instead of a linear range.
+METER_DISPLAY_RANGE = {
+ "dBSPL": (40.0, 120.0),
+ "dBu": (-40.0, 20.0),
+}
+
+DIGITAL_UNIT_LABELS = frozenset({"dB FS", "dBFS", "dB"})
+
+
+def normalize_unit_label(unit_label: str) -> str:
+ if unit_label in ("dBFS", "dB"):
+ return "dB FS"
+ return unit_label
+
+
+def meter_display_range(unit_label: str) -> tuple[float, float] | None:
+ return METER_DISPLAY_RANGE.get(normalize_unit_label(unit_label))
+
+
+def uses_iec_meter_scale(unit_label: str) -> bool:
+ return meter_display_range(unit_label) is None
+
+
+def meter_level_for_bar(
+ display_db: float, raw_db: float, unit_label: str = "dB FS"
+) -> float:
+ """Pick the dB value that drives the meter bar for a unit."""
+ if uses_iec_meter_scale(unit_label):
+ return raw_db
+ return display_db
+
+
+def meter_scale_ticks(unit_label: str) -> list[float]:
+ normalized = normalize_unit_label(unit_label)
+ display_range = meter_display_range(normalized)
+ if display_range is None:
+ return [0.0, -3.0, -6.0, -10.0, -20.0, -30.0, -40.0, -50.0, -60.0]
+ bottom, top = display_range
+ step = 10.0 if normalized == "dBSPL" else 10.0
+ ticks: list[float] = []
+ tick = top
+ while tick >= bottom:
+ ticks.append(tick)
+ tick -= step
+ return ticks
+
+
+def level_db_to_meter_fraction(level_db: float, unit_label: str = "dB FS") -> float:
+ """Map a level reading to a 0..1 meter bar height."""
+ normalized = normalize_unit_label(unit_label)
+ display_range = meter_display_range(normalized)
+ if display_range is None:
+ return dB_to_IEC(min(level_db, 0.0))
+
+ bottom, top = display_range
+ if level_db <= bottom:
+ return 0.0
+ if level_db >= top:
+ return 1.0
+ return (level_db - bottom) / (top - bottom)
+
+
+def level_db_to_iec(level_db: float, unit_label: str = "dB FS") -> float:
+ """Backward-compatible alias for meter bar height."""
+ return level_db_to_meter_fraction(level_db, unit_label)
+
+
+def iec_to_dB(iec):
+ if iec <= 0.0:
+ return -70.0
+ if iec <= 0.025:
+ return iec / 0.0025 - 70.0
+ if iec <= 0.075:
+ return (iec - 0.025) / 0.005 - 60.0
+ if iec <= 0.15:
+ return (iec - 0.075) / 0.0075 - 50.0
+ if iec <= 0.3:
+ return (iec - 0.15) / 0.015 - 40.0
+ if iec <= 0.5:
+ return (iec - 0.3) / 0.02 - 30.0
+ return (iec - 0.5) / 0.025 - 20.0
\ No newline at end of file
diff --git a/friture/input_device_catalog.py b/friture/input_device_catalog.py
new file mode 100644
index 00000000..d7fb1d64
--- /dev/null
+++ b/friture/input_device_catalog.py
@@ -0,0 +1,105 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+
+# Copyright (C) 2024 Celeste Sinéad
+
+"""Input device catalog seam — query/select capture hardware without settings UI."""
+
+from __future__ import annotations
+
+import sys
+from typing import Protocol, runtime_checkable
+
+from PyQt5 import QtCore, QtWidgets
+
+from friture.audio_ingest import get_audio_ingest
+
+NO_INPUT_DEVICE_TITLE = "No audio input device found"
+
+NO_INPUT_DEVICE_MESSAGE = """No audio input device has been found.
+
+Friture needs at least one input device. Please check your audio configuration.
+
+Friture will now exit.
+"""
+
+
+@runtime_checkable
+class InputDeviceCatalog(Protocol):
+ """Device listing and selection surface shared by ingest adapters."""
+
+ def get_readable_devices_list(self) -> list[str]:
+ ...
+
+ def get_readable_current_channels(self) -> list[str]:
+ ...
+
+ def get_readable_current_device(self) -> int:
+ ...
+
+ def get_current_first_channel(self) -> int:
+ ...
+
+ def get_current_second_channel(self) -> int:
+ ...
+
+ def select_input_device(self, index: int) -> tuple[bool, int]:
+ ...
+
+ def select_first_channel(self, index: int) -> tuple[bool, int]:
+ ...
+
+ def select_second_channel(self, index: int) -> tuple[bool, int]:
+ ...
+
+ def set_single_input(self) -> None:
+ ...
+
+ def set_duo_input(self) -> None:
+ ...
+
+
+def get_input_device_catalog() -> InputDeviceCatalog:
+ return get_audio_ingest() # type: ignore[return-value]
+
+
+def require_input_devices(
+ parent: QtWidgets.QWidget | None, catalog: InputDeviceCatalog
+) -> None:
+ """Production policy: quit when capture is required but no inputs exist."""
+ if catalog.get_readable_devices_list():
+ return
+
+ QtWidgets.QMessageBox.critical(
+ parent, NO_INPUT_DEVICE_TITLE, NO_INPUT_DEVICE_MESSAGE
+ )
+ app = QtWidgets.QApplication.instance()
+ if app is not None:
+ QtCore.QTimer.singleShot(0, app.quit)
+ sys.exit(1)
+
+
+def apply_saved_input_selection(
+ catalog: InputDeviceCatalog,
+ device_name: str,
+ first_channel: int,
+ second_channel: int,
+ duo_input: bool,
+) -> bool:
+ """Activate the last-used input device on the catalog. Returns False if unknown."""
+ devices = catalog.get_readable_devices_list()
+ if device_name not in devices:
+ return False
+
+ device_index = devices.index(device_name)
+ success, device_index = catalog.select_input_device(device_index)
+ if not success:
+ return False
+
+ catalog.select_first_channel(first_channel)
+ catalog.select_second_channel(second_channel)
+ if duo_input:
+ catalog.set_duo_input()
+ else:
+ catalog.set_single_input()
+ return True
diff --git a/friture/level_calibration.py b/friture/level_calibration.py
new file mode 100644
index 00000000..89579ba3
--- /dev/null
+++ b/friture/level_calibration.py
@@ -0,0 +1,94 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+
+# Copyright (C) 2024 Celeste Sinéad
+
+"""Map raw digital dB readings to user-calibrated display values."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+
+import numpy as np
+
+DEFAULT_UNIT_LABEL = "dB FS"
+DEFAULT_OFFSET_DB = 0.0
+
+UNIT_PRESETS = ["dB FS", "dBSPL", "dBu", "dB"]
+
+
+def unit_label_for_calibration_target(unit_label: str, target_db: float) -> str:
+ """Pick a sensible display unit when calibrating from a digital default."""
+ if unit_label not in ("dB FS", "dBFS", "dB"):
+ return unit_label
+ if target_db >= 40.0:
+ return "dBSPL"
+ if target_db <= -30.0:
+ return "dBu"
+ return unit_label
+
+
+def python_float(value) -> float:
+ """Coerce numeric values (including numpy scalars) to plain Python float."""
+ return float(value)
+
+
+def read_settings_float(settings, key: str, default: float) -> float:
+ """Read a float from QSettings, including legacy numpy scalar values."""
+ if not settings.contains(key):
+ return default
+ value = settings.value(key, default)
+ if value is None:
+ return default
+ try:
+ return python_float(value)
+ except (TypeError, ValueError):
+ return default
+
+
+def write_settings_float(settings, key: str, value: float) -> None:
+ settings.setValue(key, python_float(value))
+
+
+@dataclass
+class LevelCalibration:
+ offset_db: float = DEFAULT_OFFSET_DB
+ unit_label: str = DEFAULT_UNIT_LABEL
+ reference_note: str = ""
+
+
+def apply_calibration(raw_db, offset_db: float):
+ """Map raw dB reading(s) to calibrated display value(s)."""
+ offset = python_float(offset_db)
+ if isinstance(raw_db, np.ndarray):
+ return np.asarray(raw_db, dtype=float) + offset
+ return python_float(raw_db) + offset
+
+
+def calibration_offset_for_target(raw_db: float, target_db: float) -> float:
+ """Return offset so ``apply_calibration(raw_db, offset) == target_db``."""
+ return python_float(target_db) - python_float(raw_db)
+
+
+def resolve_calibration(
+ global_calibration: LevelCalibration,
+ local_calibration: LevelCalibration,
+ use_global: bool,
+) -> LevelCalibration:
+ if use_global:
+ return global_calibration
+ return local_calibration
+
+
+def find_global_calibration(owner) -> "GlobalCalibrationService | None":
+ from friture.global_calibration import GlobalCalibrationService
+
+ obj = owner
+ while obj is not None:
+ if isinstance(obj, GlobalCalibrationService):
+ return obj
+ calibration = getattr(obj, "global_calibration", None)
+ if isinstance(calibration, GlobalCalibrationService):
+ return calibration
+ obj = obj.parent()
+ return None
diff --git a/friture/level_data.py b/friture/level_data.py
index 14a07734..7927341e 100644
--- a/friture/level_data.py
+++ b/friture/level_data.py
@@ -20,7 +20,7 @@
from PyQt5 import QtCore
from PyQt5.QtCore import pyqtProperty
-from friture.iec import dB_to_IEC
+from friture.iec import level_db_to_meter_fraction, meter_level_for_bar
class LevelData(QtCore.QObject):
level_rms_changed = QtCore.pyqtSignal(float)
@@ -31,6 +31,8 @@ def __init__(self, parent=None):
self._level_rms = -30.
self._level_max = -30.
+ self._level_rms_raw = -30.
+ self._level_max_raw = -30.
@pyqtProperty(float, notify=level_rms_changed) # type: ignore
def level_rms(self):
@@ -38,7 +40,11 @@ def level_rms(self):
@pyqtProperty(float, notify=level_rms_changed) # type: ignore
def level_rms_iec(self):
- return dB_to_IEC(self._level_rms)
+ unit_label = self._meter_unit_label()
+ meter_db = meter_level_for_bar(
+ self._level_rms, self._level_rms_raw, unit_label
+ )
+ return level_db_to_meter_fraction(meter_db, unit_label)
@level_rms.setter # type: ignore
def level_rms(self, level_rms):
@@ -52,10 +58,37 @@ def level_max(self):
@pyqtProperty(float, notify=level_max_changed) # type: ignore
def level_max_iec(self):
- return dB_to_IEC(self._level_max)
+ unit_label = self._meter_unit_label()
+ meter_db = meter_level_for_bar(
+ self._level_max, self._level_max_raw, unit_label
+ )
+ return level_db_to_meter_fraction(meter_db, unit_label)
@level_max.setter # type: ignore
def level_max(self, level_max):
if self._level_max != level_max:
self._level_max = level_max
- self.level_max_changed.emit(level_max)
\ No newline at end of file
+ self.level_max_changed.emit(level_max)
+
+ @property
+ def level_rms_raw(self) -> float:
+ return self._level_rms_raw
+
+ @level_rms_raw.setter
+ def level_rms_raw(self, level_rms_raw: float) -> None:
+ self._level_rms_raw = level_rms_raw
+
+ @property
+ def level_max_raw(self) -> float:
+ return self._level_max_raw
+
+ @level_max_raw.setter
+ def level_max_raw(self, level_max_raw: float) -> None:
+ self._level_max_raw = level_max_raw
+
+ def _meter_unit_label(self) -> str:
+ parent = self.parent()
+ unit_label = getattr(parent, "unit_label", None)
+ if unit_label:
+ return unit_label
+ return "dB FS"
\ No newline at end of file
diff --git a/friture/level_meter.py b/friture/level_meter.py
new file mode 100644
index 00000000..3b1989f3
--- /dev/null
+++ b/friture/level_meter.py
@@ -0,0 +1,281 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+
+# Copyright (C) 2024 Celeste Sinéad
+
+"""Shared peak/RMS level meter math for sidebar and dock widgets."""
+
+from __future__ import annotations
+
+import numpy as np
+
+from friture.audiobackend import SAMPLING_RATE
+from friture.dock_analysis_widget import stereo_mode_from_chunk
+from friture.iec import level_db_to_meter_fraction, meter_level_for_bar
+from friture.freq_weighting import DEFAULT_WEIGHTING, WeightingFilter
+from friture.level_calibration import LevelCalibration, apply_calibration
+from friture.level_view_model import LevelViewModel
+from friture_extensions.exp_smoothing_conv import pyx_exp_smoothed_value
+
+DEFAULT_RESPONSE_TIME_S = 0.300
+DEFAULT_PEAK_RESPONSE_TIME_S = 0.025
+SMOOTH_DISPLAY_TIMER_PERIOD_MS = 25
+LEVEL_TEXT_LABEL_PERIOD_MS = 250
+LEVEL_TEXT_LABEL_STEPS = LEVEL_TEXT_LABEL_PERIOD_MS / SMOOTH_DISPLAY_TIMER_PERIOD_MS
+
+
+def _smoothing_kernel(response_time_s: float) -> tuple[float, np.ndarray]:
+ w = 0.65
+ n = response_time_s * SAMPLING_RATE
+ n_samples = int(5 * n)
+ alpha = 1.0 - (1.0 - w) ** (1.0 / (n + 1))
+ kernel = (1.0 - alpha) ** (np.arange(0, n_samples)[::-1])
+ return alpha, kernel
+
+
+def _peak_alpha(response_time_s: float = DEFAULT_PEAK_RESPONSE_TIME_S) -> float:
+ w = 0.65
+ n2 = response_time_s / (SMOOTH_DISPLAY_TIMER_PERIOD_MS / 1000.0)
+ return 1.0 - (1.0 - w) ** (1.0 / (n2 + 1))
+
+
+class LevelMeterProcessor:
+ def __init__(
+ self,
+ response_time_s: float = DEFAULT_RESPONSE_TIME_S,
+ peak_response_time_s: float = DEFAULT_PEAK_RESPONSE_TIME_S,
+ ) -> None:
+ self.response_time_s = response_time_s
+ self.peak_response_time_s = peak_response_time_s
+ self._alpha, self._kernel = _smoothing_kernel(response_time_s)
+ self._alpha2 = _peak_alpha(peak_response_time_s)
+ self._old_rms = 1e-30
+ self._old_max = 1e-30
+ self._old_rms_2 = 1e-30
+ self._old_max_2 = 1e-30
+ self.two_channels = False
+ self._canvas_step = 0
+ self.last_raw_rms_db = -120.0
+ self._weighting_filter = WeightingFilter()
+
+ def set_weighting(self, weighting: int) -> None:
+ self._weighting_filter.set_weighting(weighting)
+
+ def weighting(self) -> int:
+ return self._weighting_filter.weighting
+
+ def set_response_time_s(self, response_time_s: float) -> None:
+ self.response_time_s = response_time_s
+ self._alpha, self._kernel = _smoothing_kernel(response_time_s)
+
+ def reset_smoothing(self) -> None:
+ self._old_rms = 1e-30
+ self._old_max = 1e-30
+ self._old_rms_2 = 1e-30
+ self._old_max_2 = 1e-30
+ self.last_raw_rms_db = -120.0
+
+ def handle_new_data(
+ self,
+ floatdata: np.ndarray,
+ view_model: LevelViewModel,
+ calibration: LevelCalibration,
+ ) -> None:
+ updated = stereo_mode_from_chunk(floatdata, self.two_channels)
+ if updated != self.two_channels:
+ self.two_channels = updated
+ view_model.two_channels = updated
+
+ y1 = floatdata[0, :]
+ y1 = self._weighting_filter.apply(y1, channel=1)
+ raw_rms_db, raw_max_db = self._channel_raw_db(y1, channel=1)
+ self.last_raw_rms_db = raw_rms_db
+ self._apply_channel(
+ raw_rms_db,
+ raw_max_db,
+ view_model.level_data,
+ view_model.level_data_ballistic,
+ calibration,
+ )
+
+ if self.two_channels:
+ y2 = floatdata[1, :]
+ y2 = self._weighting_filter.apply(y2, channel=2)
+ raw_rms_db_2, raw_max_db_2 = self._channel_raw_db(y2, channel=2)
+ self._apply_channel(
+ raw_rms_db_2,
+ raw_max_db_2,
+ view_model.level_data_2,
+ view_model.level_data_ballistic_2,
+ calibration,
+ )
+
+ def _channel_raw_db(
+ self, samples: np.ndarray, channel: int
+ ) -> tuple[float, float]:
+ old_max = self._old_max if channel == 1 else self._old_max_2
+ old_rms = self._old_rms if channel == 1 else self._old_rms_2
+
+ if len(samples) > 0:
+ value_max = np.abs(samples).max()
+ if value_max > old_max * (1.0 - self._alpha2):
+ old_max = value_max
+ else:
+ old_max *= 1.0 - self._alpha2
+
+ value_rms = pyx_exp_smoothed_value(
+ self._kernel, self._alpha, samples**2, old_rms
+ )
+
+ if channel == 1:
+ self._old_max = old_max
+ self._old_rms = value_rms
+ else:
+ self._old_max_2 = old_max
+ self._old_rms_2 = value_rms
+
+ raw_rms_db = 10.0 * np.log10(value_rms + 1e-80)
+ raw_max_db = 20.0 * np.log10(old_max + 1e-80)
+ return raw_rms_db, raw_max_db
+
+ def _apply_channel(
+ self,
+ raw_rms_db: float,
+ raw_max_db: float,
+ level_data,
+ ballistic,
+ calibration: LevelCalibration,
+ ) -> None:
+ level_data.level_rms_raw = raw_rms_db
+ level_data.level_max_raw = raw_max_db
+ level_data.level_rms = apply_calibration(raw_rms_db, calibration.offset_db)
+ level_data.level_max = apply_calibration(raw_max_db, calibration.offset_db)
+ meter_rms_db = meter_level_for_bar(
+ level_data.level_rms, raw_rms_db, calibration.unit_label
+ )
+ meter_max_db = meter_level_for_bar(
+ level_data.level_max, raw_max_db, calibration.unit_label
+ )
+ peak_db = max(meter_max_db, meter_rms_db)
+ ballistic.peak_iec = level_db_to_meter_fraction(
+ peak_db, calibration.unit_label
+ )
+
+ def canvas_update(self, view_model: LevelViewModel, parent_visible: bool) -> None:
+ if not parent_visible:
+ return
+
+ self._canvas_step += 1
+ if self._canvas_step == LEVEL_TEXT_LABEL_STEPS:
+ view_model.level_data_slow.level_rms = view_model.level_data.level_rms
+ view_model.level_data_slow.level_max = view_model.level_data.level_max
+ if self.two_channels:
+ view_model.level_data_slow_2.level_rms = (
+ view_model.level_data_2.level_rms
+ )
+ view_model.level_data_slow_2.level_max = (
+ view_model.level_data_2.level_max
+ )
+ self._canvas_step %= LEVEL_TEXT_LABEL_STEPS
+
+
+MIN_CALIBRATION_RAW_DB = -90.0
+DEFAULT_CALIBRATION_WINDOW_SAMPLES = 4096
+
+
+def measure_raw_rms_db(
+ samples: np.ndarray,
+ *,
+ weighting: int = DEFAULT_WEIGHTING,
+) -> float:
+ if samples.size == 0:
+ return -120.0
+ weighting_filter = WeightingFilter()
+ weighting_filter.set_weighting(weighting)
+ weighted = weighting_filter.apply(np.asarray(samples, dtype=float), channel=1)
+ mean_square = float(np.mean(weighted * weighted))
+ return float(10.0 * np.log10(mean_square + 1e-80))
+
+
+def raw_rms_db_from_buffer(
+ buffer,
+ *,
+ num_samples: int = DEFAULT_CALIBRATION_WINDOW_SAMPLES,
+ weighting: int = DEFAULT_WEIGHTING,
+) -> float:
+ if buffer is None:
+ return -120.0
+
+ available = buffer.ringbuffer.offset
+ if available <= 0:
+ return -120.0
+
+ length = min(num_samples, available)
+ data = buffer.data(length)
+ if data.shape[1] == 0:
+ return -120.0
+
+ return max(
+ measure_raw_rms_db(data[channel], weighting=weighting)
+ for channel in range(data.shape[0])
+ )
+
+
+def calibration_signal_too_quiet(raw_rms_db: float) -> bool:
+ return raw_rms_db <= MIN_CALIBRATION_RAW_DB
+
+
+def calibration_raw_rms_db(
+ buffer,
+ *,
+ live_raw_rms_db: float | None = None,
+ meter: LevelMeterProcessor | None = None,
+ num_samples: int = DEFAULT_CALIBRATION_WINDOW_SAMPLES,
+ weighting: int = DEFAULT_WEIGHTING,
+) -> float:
+ """Best current raw RMS for calibration: buffer window or live meter, whichever is louder."""
+ from_buffer = raw_rms_db_from_buffer(
+ buffer,
+ num_samples=num_samples,
+ weighting=weighting,
+ )
+ if meter is not None:
+ live_raw_rms_db = meter.last_raw_rms_db
+ if live_raw_rms_db is None:
+ return from_buffer
+ return max(from_buffer, live_raw_rms_db)
+
+
+def calibration_quiet_message(
+ raw_rms_db: float,
+ *,
+ offset_db: float = 0.0,
+ unit_label: str = "dB FS",
+) -> str:
+ lines = [
+ "No usable input signal for calibration.",
+ "",
+ f"Current raw level is {raw_rms_db:.1f} dBFS.",
+ ]
+ if abs(offset_db) > 0.1:
+ apparent_db = raw_rms_db + offset_db
+ lines.extend(
+ [
+ f"Current calibration offset is {offset_db:.1f} dB ({unit_label}).",
+ f"Meters and graphs may show about {apparent_db:.1f} {unit_label}, "
+ "but that comes from the offset—not from input level.",
+ "",
+ "Reset the offset to 0 if no calibrator is connected, then calibrate "
+ "again once a reference tone is present (typically above -60 dBFS raw).",
+ ]
+ )
+ else:
+ lines.extend(
+ [
+ "",
+ "Apply a calibrator or test tone (typically above -60 dBFS raw), "
+ "then try again.",
+ ]
+ )
+ return "\n".join(lines)
+
diff --git a/friture/level_view_model.py b/friture/level_view_model.py
index f30c0194..5c19d13e 100644
--- a/friture/level_view_model.py
+++ b/friture/level_view_model.py
@@ -25,11 +25,15 @@
class LevelViewModel(QtCore.QObject):
two_channels_changed = QtCore.pyqtSignal(bool)
+ unit_label_changed = QtCore.pyqtSignal(str)
+ weighting_suffix_changed = QtCore.pyqtSignal(str)
def __init__(self, parent=None):
super().__init__(parent)
self._two_channels = False
+ self._unit_label = "dB FS"
+ self._weighting_suffix = ""
self._level_data = LevelData(self)
self._level_data_2 = LevelData(self)
@@ -48,6 +52,37 @@ def two_channels(self, two_channels):
self._two_channels = two_channels
self.two_channels_changed.emit(two_channels)
+ @pyqtProperty(str, notify=unit_label_changed) # type: ignore
+ def unit_label(self) -> str:
+ return self._unit_label
+
+ @unit_label.setter # type: ignore
+ def unit_label(self, unit_label: str) -> None:
+ if self._unit_label != unit_label:
+ self._unit_label = unit_label
+ self.unit_label_changed.emit(unit_label)
+ self._refresh_meter_iec_display()
+
+ def _refresh_meter_iec_display(self) -> None:
+ for level_data in (
+ self._level_data,
+ self._level_data_2,
+ self._level_data_slow,
+ self._level_data_slow_2,
+ ):
+ level_data.level_rms_changed.emit(level_data.level_rms)
+ level_data.level_max_changed.emit(level_data.level_max)
+
+ @pyqtProperty(str, notify=weighting_suffix_changed) # type: ignore
+ def weighting_suffix(self) -> str:
+ return self._weighting_suffix
+
+ @weighting_suffix.setter # type: ignore
+ def weighting_suffix(self, weighting_suffix: str) -> None:
+ if self._weighting_suffix != weighting_suffix:
+ self._weighting_suffix = weighting_suffix
+ self.weighting_suffix_changed.emit(weighting_suffix)
+
@pyqtProperty(LevelData, constant = True) # type: ignore
def level_data(self):
return self._level_data
diff --git a/friture/levels.py b/friture/levels.py
index e47c02b4..3414dbd6 100644
--- a/friture/levels.py
+++ b/friture/levels.py
@@ -3,158 +3,56 @@
# Copyright (C) 2009 Timoth?Lecomte
-# This file is part of Friture.
-#
-# Friture is free software: you can redistribute it and/or modify
-# it under the terms of the GNU General Public License version 3 as published by
-# the Free Software Foundation.
-#
-# Friture is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-# GNU General Public License for more details.
-#
-# You should have received a copy of the GNU General Public License
-# along with Friture. If not, see .
-
"""Level widget that displays peak and RMS levels for 1 or 2 ports."""
from PyQt5.QtCore import QObject
-import numpy as np
-
-from friture.levels_settings import Levels_Settings_Dialog # settings dialog
-from friture.audioproc import audioproc
-from friture.iec import dB_to_IEC
-from friture_extensions.exp_smoothing_conv import pyx_exp_smoothed_value
-from friture.audiobackend import SAMPLING_RATE
-SMOOTH_DISPLAY_TIMER_PERIOD_MS = 25
-LEVEL_TEXT_LABEL_PERIOD_MS = 250
+from friture.levels_settings import Levels_Settings_Dialog
+from friture.level_meter import LevelMeterProcessor
+from friture.dock_analysis_widget import stereo_mode_from_chunk
-LEVEL_TEXT_LABEL_STEPS = LEVEL_TEXT_LABEL_PERIOD_MS / SMOOTH_DISPLAY_TIMER_PERIOD_MS
class Levels_Widget(QObject):
- def __init__(self, parent, view_model):
+ def __init__(self, parent, view_model, global_calibration) -> None:
super().__init__(parent)
self._parent = parent
self.level_view_model = view_model
-
+ self._global_calibration = global_calibration
self.audiobuffer = None
-
- # initialize the settings dialog
self.settings_dialog = Levels_Settings_Dialog(parent)
+ self._meter = LevelMeterProcessor()
+ self._global_calibration.changed.connect(self._sync_unit_label)
+ self._sync_unit_label()
- # initialize the class instance that will do the fft
- self.proc = audioproc()
-
- # time = SMOOTH_DISPLAY_TIMER_PERIOD_MS/1000. #DISPLAY
- # time = 0.025 #IMPULSE setting for a sound level meter
- # time = 0.125 #FAST setting for a sound level meter
- # time = 1. #SLOW setting for a sound level meter
- self.response_time = 0.300 # 300ms is a common value for VU meters
- # an exponential smoothing filter is a simple IIR filter
- # s_i = alpha*x_i + (1-alpha)*s_{i-1}
- # we compute alpha so that the n most recent samples represent 100*w percent of the output
- w = 0.65
- n = self.response_time * SAMPLING_RATE
- N = 5*n
- self.alpha = 1. - (1. - w) ** (1. / (n + 1))
- self.kernel = (1. - self.alpha) ** (np.arange(0, N)[::-1])
- # first channel
- self.old_rms = 1e-30
- self.old_max = 1e-30
- # second channel
- self.old_rms_2 = 1e-30
- self.old_max_2 = 1e-30
-
- response_time_peaks = 0.025 # 25ms for instantaneous peaks
- n2 = response_time_peaks / (SMOOTH_DISPLAY_TIMER_PERIOD_MS / 1000.)
- self.alpha2 = 1. - (1. - w) ** (1. / (n2 + 1))
-
- self.two_channels = False
-
- self.i = 0
-
- # method
- def set_buffer(self, buffer):
- self.audiobuffer = buffer
+ @property
+ def last_raw_rms_db(self) -> float:
+ return self._meter.last_raw_rms_db
- def handle_new_data(self, floatdata):
- if floatdata.shape[0] > 1 and not self.two_channels:
- self.two_channels = True
- self.level_view_model.two_channels = True
- elif floatdata.shape[0] == 1 and self.two_channels:
- self.two_channels = False
- self.level_view_model.two_channels = False
-
- # first channel
- y1 = floatdata[0, :]
-
- # exponential smoothing for max
- if len(y1) > 0:
- value_max = np.abs(y1).max()
- if value_max > self.old_max * (1. - self.alpha2):
- self.old_max = value_max
- else:
- # exponential decrease
- self.old_max *= (1. - self.alpha2)
-
- # exponential smoothing for RMS
- value_rms = pyx_exp_smoothed_value(self.kernel, self.alpha, y1 ** 2, self.old_rms)
- self.old_rms = value_rms
-
- self.level_view_model.level_data.level_rms = 10. * np.log10(value_rms + 0. * 1e-80)
- self.level_view_model.level_data.level_max = 20. * np.log10(self.old_max + 0. * 1e-80)
- self.level_view_model.level_data_ballistic.peak_iec = dB_to_IEC(max(self.level_view_model.level_data.level_max, self.level_view_model.level_data.level_rms))
-
- if self.two_channels:
- # second channel
- y2 = floatdata[1, :]
-
- # exponential smoothing for max
- if len(y2) > 0:
- value_max = np.abs(y2).max()
- if value_max > self.old_max_2 * (1. - self.alpha2):
- self.old_max_2 = value_max
- else:
- # exponential decrease
- self.old_max_2 *= (1. - self.alpha2)
-
- # exponential smoothing for RMS
- value_rms = pyx_exp_smoothed_value(self.kernel, self.alpha, y2 ** 2, self.old_rms_2)
- self.old_rms_2 = value_rms
-
- self.level_view_model.level_data_2.level_rms = 10. * np.log10(value_rms + 0. * 1e-80)
- self.level_view_model.level_data_2.level_max = 20. * np.log10(self.old_max_2 + 0. * 1e-80)
- self.level_view_model.level_data_ballistic_2.peak_iec = dB_to_IEC(max(self.level_view_model.level_data_2.level_max, self.level_view_model.level_data_2.level_rms))
-
- # method
- def canvasUpdate(self):
- if not self._parent.isVisible():
- return
-
- self.i += 1
+ def _sync_unit_label(self) -> None:
+ self.level_view_model.unit_label = self._global_calibration.calibration.unit_label
- if self.i == LEVEL_TEXT_LABEL_STEPS:
- self.level_view_model.level_data_slow.level_rms = self.level_view_model.level_data.level_rms
- self.level_view_model.level_data_slow.level_max = self.level_view_model.level_data.level_max
+ def set_buffer(self, buffer) -> None:
+ self.audiobuffer = buffer
- if self.two_channels:
- self.level_view_model.level_data_slow_2.level_rms = self.level_view_model.level_data_2.level_rms
- self.level_view_model.level_data_slow_2.level_max = self.level_view_model.level_data_2.level_max
+ def handle_new_data(self, floatdata) -> None:
+ self._meter.handle_new_data(
+ floatdata,
+ self.level_view_model,
+ self._global_calibration.calibration,
+ )
- self.i = self.i % LEVEL_TEXT_LABEL_STEPS
+ def canvasUpdate(self) -> None:
+ if not self._parent.isVisible():
+ return
+ self._meter.canvas_update(self.level_view_model, True)
- # slot
- def settings_called(self, checked):
+ def settings_called(self, checked) -> None:
self.settings_dialog.show()
- # method
- def saveState(self, settings):
+ def saveState(self, settings) -> None:
self.settings_dialog.saveState(settings)
- # method
- def restoreState(self, settings):
+ def restoreState(self, settings) -> None:
self.settings_dialog.restoreState(settings)
diff --git a/friture/levels_settings.py b/friture/levels_settings.py
index b87ca6fb..0076d95d 100644
--- a/friture/levels_settings.py
+++ b/friture/levels_settings.py
@@ -18,6 +18,7 @@
# along with Friture. If not, see .
from PyQt5 import QtWidgets
+from friture.settings_dialog_layout import create_form_layout
class Levels_Settings_Dialog(QtWidgets.QDialog):
@@ -27,7 +28,7 @@ def __init__(self, parent):
self.setWindowTitle("Levels settings")
- self.formLayout = QtWidgets.QFormLayout(self)
+ self.formLayout = create_form_layout(self)
# self.doubleSpinBox_timerange = QtWidgets.QDoubleSpinBox(self)
# self.doubleSpinBox_timerange.setDecimals(1)
@@ -40,8 +41,6 @@ def __init__(self, parent):
# self.formLayout.addRow("Time range:", self.doubleSpinBox_timerange)
self.formLayout.addRow("No settings for the levels.", None)
- self.setLayout(self.formLayout)
-
# self.doubleSpinBox_timerange.valueChanged.connect(self.parent().timerangechanged)
# method
diff --git a/friture/longlevels.py b/friture/longlevels.py
index 24b64b3f..e9c4d3e7 100644
--- a/friture/longlevels.py
+++ b/friture/longlevels.py
@@ -27,7 +27,10 @@
DEFAULT_LEVEL_MIN,
DEFAULT_LEVEL_MAX,
DEFAULT_MAXTIME,
- DEFAULT_RESPONSE_TIME)
+ DEFAULT_RESPONSE_TIME,
+ DEFAULT_UNIT_LABEL)
+from friture.calibration_override import CalibrationOverrideMixin
+from friture.level_calibration import apply_calibration
from friture.audioproc import audioproc
from .signal.decimate import decimate
from .ringbuffer import RingBuffer
@@ -91,12 +94,13 @@ def push(self, x):
return x_dec
-class LongLevelWidget(QObject):
+class LongLevelWidget(QObject, CalibrationOverrideMixin):
def __init__(self, parent):
super().__init__(parent)
self.logger = logging.getLogger(__name__)
+ self.init_calibration_override(parent)
self._long_levels_data = Scope_Data(GetStore())
@@ -109,6 +113,9 @@ def __init__(self, parent):
self._long_levels_data.horizontal_axis.name = "Time (sec)"
self._long_levels_data.horizontal_axis.setTrackerFormatter(lambda x: "%#.3g sec" % (x))
+ self.calibration = self.local_calibration # compat for settings/tests
+ self.last_raw_rms_db = -200.0
+
self.level_min = DEFAULT_LEVEL_MIN
self.level_max = DEFAULT_LEVEL_MAX
self._long_levels_data.vertical_axis.setRange(self.level_min, self.level_max)
@@ -137,6 +144,55 @@ def __init__(self, parent):
# ringbuffer for the subsampled data
self.ringbuffer = RingBuffer()
+ self._sync_calibration_display()
+
+ def on_effective_calibration_changed(self) -> None:
+ self._sync_calibration_display()
+ self._refresh_curve()
+
+ def _sync_calibration_display(self) -> None:
+ unit = self.effective_calibration().unit_label
+ self._long_levels_data.vertical_axis.name = f"Level ({unit} RMS)"
+ self._long_levels_data.vertical_axis.setTrackerFormatter(
+ lambda value, label=unit: "%#.3g %s" % (value, label)
+ )
+
+ def set_calibration_offset(self, offset_db: float) -> None:
+ self.set_local_calibration_offset(offset_db)
+
+ def set_unit_label(self, unit_label: str) -> None:
+ self.set_local_unit_label(unit_label)
+
+ def set_reference_note(self, note: str) -> None:
+ self.set_local_reference_note(note)
+
+ def calibrate_to_target(self, target_db: float) -> None:
+ from friture.level_meter import raw_rms_db_from_buffer
+
+ raw_rms_db = raw_rms_db_from_buffer(self.audiobuffer)
+ self.calibrate_local_to_target(raw_rms_db, target_db)
+
+ def _refresh_curve(self) -> None:
+ if not getattr(self, "length_samples", 0):
+ return
+ time = getattr(self, "time", None)
+ if time is None:
+ return
+
+ raw_levels = self.ringbuffer.data(self.length_samples)
+ display_levels = apply_calibration(
+ raw_levels[0, :], self.effective_calibration().offset_db
+ )
+ scaled_t = self.time / self.length_seconds
+ scaled_y = np.clip(
+ 1.0
+ - (display_levels - self.level_min)
+ / (self.level_max - self.level_min),
+ 0.0,
+ 1.0,
+ )
+ self._curve.setData(scaled_t, scaled_y)
+
def qml_file_name(self):
return "Scope.qml"
@@ -177,9 +233,13 @@ def handle_new_data(self, floatdata):
self.level, self.zf = pyx_lfilter_float64_1D(self.b, self.a, y0_squared_dec, self.zf)
- self.level_rms = 10. * np.log10(max(self.level, 1e-150))
+ raw_rms = 10.0 * np.log10(max(self.level, 1e-150))
+ self.last_raw_rms_db = raw_rms
+ self.level_rms = apply_calibration(
+ raw_rms, self.effective_calibration().offset_db
+ )
- l = np.array([self.level_rms])
+ l = np.array([raw_rms])
l.shape = (1, 1)
self.ringbuffer.push(l, 0)
@@ -187,12 +247,7 @@ def handle_new_data(self, floatdata):
self.old_index += needed
self.time = np.arange(self.length_samples) / self.subsampled_sampling_rate
-
- levels = self.ringbuffer.data(self.length_samples)
-
- scaled_t = self.time / self.length_seconds
- scaled_y = np.clip(1. - (levels[0, :] - self.level_min) / (self.level_max - self.level_min), 0., 1.)
- self._curve.setData(scaled_t, scaled_y)
+ self._refresh_curve()
# method
def canvasUpdate(self):
@@ -202,10 +257,12 @@ def canvasUpdate(self):
def setmin(self, value):
self.level_min = value
self._long_levels_data.vertical_axis.setRange(self.level_min, self.level_max)
+ self._refresh_curve()
def setmax(self, value):
self.level_max = value
self._long_levels_data.vertical_axis.setRange(self.level_min, self.level_max)
+ self._refresh_curve()
def setduration(self, value):
self.length_seconds = value
@@ -231,12 +288,15 @@ def setresptime(self, value):
# slot
def settings_called(self, checked):
+ self.settings_dialog.sync_from_widget()
self.settings_dialog.show()
# method
def saveState(self, settings):
self.settings_dialog.saveState(settings)
+ self.save_calibration_override_state(settings)
- # method
def restoreState(self, settings):
self.settings_dialog.restoreState(settings)
+ self.restore_calibration_override_state(settings)
+ self.on_effective_calibration_changed()
diff --git a/friture/longlevels_settings.py b/friture/longlevels_settings.py
index 0965c88c..a9ea1586 100644
--- a/friture/longlevels_settings.py
+++ b/friture/longlevels_settings.py
@@ -3,29 +3,25 @@
# Copyright (C) 2009 Timoth?Lecomte
-# This file is part of Friture.
-#
-# Friture is free software: you can redistribute it and/or modify
-# it under the terms of the GNU General Public License version 3 as published by
-# the Free Software Foundation.
-#
-# Friture is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-# GNU General Public License for more details.
-#
-# You should have received a copy of the GNU General Public License
-# along with Friture. If not, see .
-
from PyQt5 import QtWidgets
-from friture.audiobackend import SAMPLING_RATE
+
+from friture.calibration_settings_panel import CalibrationFormRows
+from friture.level_calibration import read_settings_float, unit_label_for_calibration_target, write_settings_float
+from friture.level_meter import (
+ calibration_quiet_message,
+ calibration_raw_rms_db,
+ calibration_signal_too_quiet,
+)
+from friture.settings_dialog_layout import create_form_layout
DEFAULT_MAXTIME = 600
-#DEFAULT_MINTIME = 20
DEFAULT_LEVEL_MIN = -70
DEFAULT_LEVEL_MAX = -20
DEFAULT_RESPONSE_TIME = 20
-#DEFAULT_RESPONSE_TIME_INDEX = 0
+DEFAULT_CALIBRATION_OFFSET_DB = 0.0
+DEFAULT_UNIT_LABEL = "dB FS"
+
+UNIT_PRESETS = ["dB FS", "dBSPL", "dBu", "dB"]
class LongLevels_Settings_Dialog(QtWidgets.QDialog):
@@ -33,9 +29,10 @@ class LongLevels_Settings_Dialog(QtWidgets.QDialog):
def __init__(self, parent, view_model):
super().__init__(parent)
+ self._widget = view_model
self.setWindowTitle("Long levels settings")
- self.formLayout = QtWidgets.QFormLayout(self)
+ self.formLayout = create_form_layout(self)
self.spinBox_specmin = QtWidgets.QSpinBox(self)
self.spinBox_specmin.setKeyboardTracking(False)
@@ -69,27 +66,90 @@ def __init__(self, parent, view_model):
self.spinBox_timemax.setObjectName("longlevels_timemax")
self.spinBox_timemax.setSuffix(" sec")
-
self.formLayout.addRow("Max:", self.spinBox_specmax)
self.formLayout.addRow("Min:", self.spinBox_specmin)
self.formLayout.addRow("Response Time", self.spinBox_resptime)
self.formLayout.addRow("Time Range:", self.spinBox_timemax)
- self.setLayout(self.formLayout)
+ self.calibration_rows = CalibrationFormRows(
+ self.formLayout, include_use_global=True
+ )
+ self.calibration_rows.checkBox_use_global.toggled.connect(
+ self._widget.set_use_global_calibration
+ )
+ self.calibration_rows.doubleSpinBox_offset.valueChanged.connect(
+ self._widget.set_calibration_offset
+ )
+ self.calibration_rows.comboBox_unit.currentTextChanged.connect(
+ self._widget.set_unit_label
+ )
+ self.calibration_rows.lineEdit_reference.textChanged.connect(
+ self._widget.set_reference_note
+ )
+ self.calibration_rows.button_calibrate.clicked.connect(self._calibrate_from_current)
self.spinBox_specmin.valueChanged.connect(view_model.setmin)
self.spinBox_specmax.valueChanged.connect(view_model.setmax)
self.spinBox_resptime.valueChanged.connect(view_model.setresptime)
self.spinBox_timemax.valueChanged.connect(view_model.setduration)
- # method
+ def sync_from_widget(self) -> None:
+ self.calibration_rows.set_use_global(self._widget.use_global_calibration)
+ self.calibration_rows.load(
+ self._widget.local_calibration.offset_db,
+ self._widget.local_calibration.unit_label,
+ self._widget.local_calibration.reference_note,
+ )
+
+ def _calibrate_from_current(self) -> None:
+ raw_rms_db = calibration_raw_rms_db(
+ self._widget.audiobuffer,
+ live_raw_rms_db=self._widget.last_raw_rms_db,
+ )
+ if calibration_signal_too_quiet(raw_rms_db):
+ cal = self._widget.effective_calibration()
+ QtWidgets.QMessageBox.warning(
+ self,
+ "Calibrate level",
+ calibration_quiet_message(
+ raw_rms_db,
+ offset_db=cal.offset_db,
+ unit_label=cal.unit_label,
+ ),
+ )
+ return
+ target_db, ok = QtWidgets.QInputDialog.getDouble(
+ self,
+ "Calibrate level",
+ f"Raw input is {raw_rms_db:.1f} dBFS.\n"
+ "It should read (dB):",
+ value=94.0,
+ decimals=1,
+ )
+ if ok:
+ unit_label = unit_label_for_calibration_target(
+ self._widget.local_calibration.unit_label, target_db
+ )
+ if unit_label != self._widget.local_calibration.unit_label:
+ self._widget.set_local_unit_label(unit_label)
+ self._widget.calibrate_local_to_target(raw_rms_db, target_db)
+ self.calibration_rows.load(
+ self._widget.local_calibration.offset_db,
+ self._widget.local_calibration.unit_label,
+ self._widget.local_calibration.reference_note,
+ )
+
def saveState(self, settings):
settings.setValue("Min", self.spinBox_specmin.value())
settings.setValue("Max", self.spinBox_specmax.value())
settings.setValue("RespTime", self.spinBox_resptime.value())
settings.setValue("TimeMax", self.spinBox_timemax.value())
+ settings.setValue("useGlobalCalibration", self.calibration_rows.use_global())
+ offset_db, unit_label, reference_note = self.calibration_rows.save()
+ write_settings_float(settings, "offsetDb", offset_db)
+ settings.setValue("unitLabel", unit_label)
+ settings.setValue("referenceNote", reference_note)
- # method
def restoreState(self, settings):
colorMin = settings.value("Min", DEFAULT_LEVEL_MIN, type=int)
self.spinBox_specmin.setValue(colorMin)
@@ -99,3 +159,11 @@ def restoreState(self, settings):
self.spinBox_resptime.setValue(resptime)
timemax = settings.value("TimeMax", DEFAULT_MAXTIME, type=int)
self.spinBox_timemax.setValue(timemax)
+ self.calibration_rows.set_use_global(
+ settings.value("useGlobalCalibration", True, type=bool)
+ )
+ self.calibration_rows.load(
+ read_settings_float(settings, "offsetDb", DEFAULT_CALIBRATION_OFFSET_DB),
+ settings.value("unitLabel", DEFAULT_UNIT_LABEL, type=str),
+ settings.value("referenceNote", "", type=str),
+ )
diff --git a/friture/mic_cal_file.py b/friture/mic_cal_file.py
new file mode 100644
index 00000000..bb222bbc
--- /dev/null
+++ b/friture/mic_cal_file.py
@@ -0,0 +1,131 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+
+# Copyright (C) 2024 Celeste Sinéad
+
+"""Parse microphone calibration files (factory .txt and REW-style .cal)."""
+
+from __future__ import annotations
+
+import os
+import re
+from dataclasses import dataclass
+
+import numpy as np
+
+_FACTORY_HEADER_RE = re.compile(
+ r"^\*\s*(?P[\d.]+)\s*Hz\s+(?P-?[\d.]+)\s*$",
+ re.IGNORECASE,
+)
+_SENSITIVITY_DBFS_RE = re.compile(
+ r"^Sensitivity\s+(?P-?[\d.]+)\s*dBFS\s*$",
+ re.IGNORECASE,
+)
+_SENS_FACTOR_RE = re.compile(
+ r"^Sens\s+Factor\s*=\s*(?P-?[\d.]+)\s*dB",
+ re.IGNORECASE,
+)
+_DATA_LINE_RE = re.compile(
+ r"^\s*(?P[\d.]+)\s*[, \t]\s*(?P-?[\d.]+)(?:\s*[, \t]\s*[-\d.]+)?\s*(?:$|[\t,])"
+)
+
+
+class MicCalFileError(ValueError):
+ pass
+
+
+@dataclass(frozen=True)
+class MicCalFile:
+ frequencies_hz: np.ndarray
+ corrections_db: np.ndarray
+ sensitivity_db: float | None = None
+ reference_freq_hz: float | None = None
+ source_path: str = ""
+
+ @property
+ def has_frequency_data(self) -> bool:
+ return len(self.frequencies_hz) >= 2
+
+ def interpolate_db(self, frequencies_hz: np.ndarray) -> np.ndarray:
+ if not self.has_frequency_data:
+ return np.zeros_like(frequencies_hz, dtype=float)
+
+ freqs = np.asarray(frequencies_hz, dtype=float)
+ cal_freq = self.frequencies_hz
+ cal_corr = self.corrections_db
+ log_cal = np.log10(cal_freq)
+ log_freq = np.log10(np.clip(freqs, cal_freq[0], cal_freq[-1]))
+ return np.interp(log_freq, log_cal, cal_corr)
+
+ def summary(self) -> str:
+ parts: list[str] = []
+ if self.source_path:
+ parts.append(os.path.basename(self.source_path))
+ if self.sensitivity_db is not None:
+ ref = (
+ f" @ {self.reference_freq_hz:g} Hz"
+ if self.reference_freq_hz is not None
+ else ""
+ )
+ parts.append(f"sensitivity {self.sensitivity_db:.1f} dB{ref}")
+ if self.has_frequency_data:
+ parts.append(f"{len(self.frequencies_hz)} frequency points")
+ return ", ".join(parts) if parts else "Empty calibration file"
+
+ @classmethod
+ def parse_text(cls, text: str, source_path: str = "") -> MicCalFile:
+ sensitivity_db: float | None = None
+ reference_freq_hz: float | None = None
+ frequencies: list[float] = []
+ corrections: list[float] = []
+
+ for line in text.splitlines():
+ stripped = line.strip()
+ if not stripped:
+ continue
+
+ factory_match = _FACTORY_HEADER_RE.match(stripped)
+ if factory_match:
+ reference_freq_hz = float(factory_match.group("freq"))
+ sensitivity_db = float(factory_match.group("sens"))
+ continue
+
+ sensitivity_match = _SENSITIVITY_DBFS_RE.match(stripped)
+ if sensitivity_match:
+ sensitivity_db = float(sensitivity_match.group("sens"))
+ continue
+
+ sens_factor_match = _SENS_FACTOR_RE.match(stripped)
+ if sens_factor_match:
+ sensitivity_db = float(sens_factor_match.group("sens"))
+ continue
+
+ data_match = _DATA_LINE_RE.match(stripped)
+ if not data_match:
+ continue
+
+ freq = float(data_match.group("freq"))
+ gain = float(data_match.group("gain"))
+ if frequencies and freq <= frequencies[-1]:
+ raise MicCalFileError(
+ f"Frequencies must increase (got {freq} after {frequencies[-1]})"
+ )
+ frequencies.append(freq)
+ corrections.append(gain)
+
+ if len(frequencies) < 2:
+ raise MicCalFileError("Calibration file must contain at least two frequency points")
+
+ return cls(
+ frequencies_hz=np.asarray(frequencies, dtype=float),
+ corrections_db=np.asarray(corrections, dtype=float),
+ sensitivity_db=sensitivity_db,
+ reference_freq_hz=reference_freq_hz,
+ source_path=source_path,
+ )
+
+
+def load_mic_cal_file(path: str) -> MicCalFile:
+ with open(path, encoding="utf-8", errors="replace") as handle:
+ text = handle.read()
+ return MicCalFile.parse_text(text, source_path=os.path.abspath(path))
diff --git a/friture/octavespectrum.py b/friture/octavespectrum.py
index 1e49dd34..7e3f5436 100644
--- a/friture/octavespectrum.py
+++ b/friture/octavespectrum.py
@@ -20,6 +20,7 @@
from PyQt5.QtCore import QObject
from numpy import log10, array, arange
+from friture.calibrated_display_range import CalibratedDisplayRangeMixin
from friture.histplot import HistPlot
from friture.octavefilters import Octave_Filters
from friture.octavespectrum_settings import (OctaveSpectrum_Settings_Dialog, # settings dialog
@@ -35,10 +36,12 @@
from friture.audiobackend import SAMPLING_RATE
+from friture.global_frequency_calibration import frequency_adjustment_db_for_owner
+
SMOOTH_DISPLAY_TIMER_PERIOD_MS = 25
-class OctaveSpectrum_Widget(QObject):
+class OctaveSpectrum_Widget(QObject, CalibratedDisplayRangeMixin):
def __init__(self, parent):
super().__init__(parent)
@@ -47,12 +50,10 @@ def __init__(self, parent):
self.PlotZoneSpect = HistPlot(self)
- self.spec_min = DEFAULT_SPEC_MIN
- self.spec_max = DEFAULT_SPEC_MAX
self.weighting = DEFAULT_WEIGHTING
self.response_time = DEFAULT_RESPONSE_TIME
- self.PlotZoneSpect.setspecrange(self.spec_min, self.spec_max)
+ self.init_calibrated_display_range(parent, DEFAULT_SPEC_MIN, DEFAULT_SPEC_MAX)
self.PlotZoneSpect.setweighting(self.weighting)
self.filters = Octave_Filters(DEFAULT_BANDSPEROCTAVE)
@@ -119,6 +120,8 @@ def handle_new_data(self, floatdata):
epsilon = 1e-30
db_spectrogram = 10 * log10(sp + epsilon) + w
+ adjustment = frequency_adjustment_db_for_owner(self, self.filters.fi)
+ db_spectrogram = db_spectrogram + adjustment
self.PlotZoneSpect.setdata(self.filters.flow, self.filters.fhigh, self.filters.f_nominal, db_spectrogram)
# method
@@ -126,12 +129,13 @@ def canvasUpdate(self):
self.PlotZoneSpect.draw()
def setmin(self, value):
- self.spec_min = value
- self.PlotZoneSpect.setspecrange(self.spec_min, self.spec_max)
+ self.set_base_spec_min(value)
def setmax(self, value):
- self.spec_max = value
- self.PlotZoneSpect.setspecrange(self.spec_min, self.spec_max)
+ self.set_base_spec_max(value)
+
+ def _apply_calibrated_spec_range(self, spec_min: float, spec_max: float) -> None:
+ self.PlotZoneSpect.setspecrange(spec_min, spec_max)
def setweighting(self, weighting):
self.weighting = weighting
diff --git a/friture/octavespectrum_settings.py b/friture/octavespectrum_settings.py
index 19766a89..245b8b5d 100644
--- a/friture/octavespectrum_settings.py
+++ b/friture/octavespectrum_settings.py
@@ -20,6 +20,7 @@
import logging
from PyQt5 import QtWidgets
+from friture.settings_dialog_layout import create_form_layout
# shared with octavespectrum.py
DEFAULT_SPEC_MIN = -80
@@ -42,7 +43,7 @@ def __init__(self, parent, view_model):
self.setWindowTitle("Octave Spectrum settings")
- self.formLayout = QtWidgets.QFormLayout(self)
+ self.formLayout = create_form_layout(self)
self.comboBox_bandsperoctave = QtWidgets.QComboBox(self)
self.comboBox_bandsperoctave.setObjectName("comboBox_bandsperoctave")
@@ -92,8 +93,6 @@ def __init__(self, parent, view_model):
self.formLayout.addRow("Middle-ear weighting:", self.comboBox_weighting)
self.formLayout.addRow("Response time:", self.comboBox_response_time)
- self.setLayout(self.formLayout)
-
self.comboBox_bandsperoctave.currentIndexChanged.connect(self.bandsperoctavechanged)
self.spinBox_specmin.valueChanged.connect(view_model.setmin)
self.spinBox_specmax.valueChanged.connect(view_model.setmax)
diff --git a/friture/pitch_tracker_settings.py b/friture/pitch_tracker_settings.py
index b39832bf..b9841102 100644
--- a/friture/pitch_tracker_settings.py
+++ b/friture/pitch_tracker_settings.py
@@ -22,6 +22,7 @@
from typing import Any
from friture.audiobackend import SAMPLING_RATE
+from friture.settings_dialog_layout import create_form_layout
# Pitch tracker defaults:
DEFAULT_FFT_SIZE = 4096
@@ -37,7 +38,7 @@ class PitchTrackerSettingsDialog(QtWidgets.QDialog):
def __init__(self, parent: QtWidgets.QWidget, view_model: Any) -> None:
super().__init__(parent)
self.setWindowTitle("Pitch Tracker Settings")
- self.form_layout = QtWidgets.QFormLayout(self)
+ self.form_layout = create_form_layout(self)
self.min_freq = QtWidgets.QSpinBox(self)
self.min_freq.setMinimum(10)
@@ -89,8 +90,6 @@ def __init__(self, parent: QtWidgets.QWidget, view_model: Any) -> None:
self.min_db.valueChanged.connect(view_model.set_min_db) # type: ignore
self.form_layout.addRow("Min Amplitude:", self.min_db)
- self.setLayout(self.form_layout)
-
def save_state(self, settings: QSettings) -> None:
settings.setValue("min_freq", self.min_freq.value())
settings.setValue("max_freq", self.max_freq.value())
diff --git a/friture/plotting/frequency_scales.py b/friture/plotting/frequency_scales.py
index 84b22ee6..86c9a098 100644
--- a/friture/plotting/frequency_scales.py
+++ b/friture/plotting/frequency_scales.py
@@ -62,6 +62,26 @@ def roundWithPrecision(x, prec):
return candidates[i]
+class IEC(object):
+ NAME = "IEC"
+
+ @staticmethod
+ def transform(dB: float) -> float:
+ from friture.iec import dB_to_IEC
+
+ return dB_to_IEC(dB)
+
+ @staticmethod
+ def inverse(iec: float) -> float:
+ from friture.iec import iec_to_dB
+
+ return iec_to_dB(iec)
+
+ @staticmethod
+ def ticks(scale_min, scale_max) -> tuple[list[float], list[float]]:
+ return Linear.ticks(scale_min, scale_max)
+
+
class Linear(object):
NAME = 'Linear'
diff --git a/friture/portaudio_ingest.py b/friture/portaudio_ingest.py
new file mode 100644
index 00000000..6a977be6
--- /dev/null
+++ b/friture/portaudio_ingest.py
@@ -0,0 +1,476 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+
+# Copyright (C) 2009 Timothée Lecomte
+
+# PortAudio capture adapter — loaded only when production ingest is created.
+
+import logging
+import math
+
+import numpy as np
+import rtmixer
+import sounddevice
+from numpy import (
+ concatenate,
+ float32,
+ float64,
+ frombuffer,
+ int8,
+ int16,
+ ndarray,
+ vstack,
+)
+from PyQt5 import QtCore
+
+from friture.audio_ingest import FRAMES_PER_BUFFER, SAMPLING_RATE
+
+
+class PortAudioIngest(QtCore.QObject):
+
+ underflow = QtCore.pyqtSignal()
+ new_data_available = QtCore.pyqtSignal(ndarray, float, bool)
+
+ def __init__(self):
+ super().__init__()
+
+ self.logger = logging.getLogger(__name__)
+
+ self.duo_input = False
+
+ self.logger.info("Initializing PortAudio ingest")
+
+ self.input_devices = self.get_input_devices()
+ self.output_devices = self.get_output_devices()
+
+ self.logger.info(
+ "Found %d input devices and %d output devices",
+ len(self.input_devices),
+ len(self.output_devices),
+ )
+
+ self.device = None
+ self.first_channel = None
+ self.second_channel = None
+
+ self.stream = None
+ self.ringBuffer = None
+ self.action = None
+ self.nchannels_max = 0
+ self.stream_start_time = 0.0
+ self.stream_read_index = 0
+
+ for device in self.input_devices:
+ try:
+ (self.stream, self.ringBuffer, self.action, self.nchannels_max) = (
+ self.open_stream(device)
+ )
+ self.stream.start()
+ self.device = device
+ self.stream_start_time = self.stream.time
+ self.stream_read_index = 0
+ self.logger.info("Success")
+ break
+ except Exception:
+ self.logger.exception("Failed to open stream")
+
+ if self.device is not None:
+ self.first_channel = 0
+ nchannels = self.get_current_device_nchannels()
+ if nchannels == 1:
+ self.second_channel = 0
+ else:
+ self.second_channel = 1
+
+ self.xruns = 0
+ self.chunk_number = 0
+ self.devices_with_timing_errors = []
+
+ def close(self):
+ if self.stream is not None:
+ self.stream.stop()
+ self.stream = None
+
+ def get_readable_devices_list(self):
+ input_devices = self.get_input_devices()
+
+ raw_devices = sounddevice.query_devices()
+
+ try:
+ default_input_device = sounddevice.query_devices(kind="input")
+ default_input_device["index"] = raw_devices.index(default_input_device)
+ except sounddevice.PortAudioError:
+ self.logger.exception("Failed to query the default input device")
+ default_input_device = None
+
+ devices_list = []
+ for device in input_devices:
+ api = sounddevice.query_hostapis(device["hostapi"])["name"]
+
+ if (
+ default_input_device is not None
+ and device["index"] == default_input_device["index"]
+ ):
+ extra_info = " (default)"
+ else:
+ extra_info = ""
+
+ nchannels = device["max_input_channels"]
+
+ desc = "%s (%d channels) (%s) %s" % (
+ device["name"],
+ nchannels,
+ api,
+ extra_info,
+ )
+
+ devices_list += [desc]
+
+ return devices_list
+
+ def get_readable_output_devices_list(self):
+ output_devices = self.get_output_devices()
+
+ raw_devices = sounddevice.query_devices()
+ default_output_device = sounddevice.query_devices(kind="output")
+ default_output_device["index"] = raw_devices.index(default_output_device)
+
+ devices_list = []
+ for device in output_devices:
+ api = sounddevice.query_hostapis(device["hostapi"])["name"]
+
+ if (
+ default_output_device is not None
+ and device["index"] == default_output_device["index"]
+ ):
+ extra_info = " (default)"
+ else:
+ extra_info = ""
+
+ nchannels = device["max_output_channels"]
+
+ desc = "%s (%d channels) (%s) %s" % (
+ device["name"],
+ nchannels,
+ api,
+ extra_info,
+ )
+
+ devices_list += [desc]
+
+ return devices_list
+
+ def get_default_input_device(self):
+ try:
+ index = sounddevice.default.device[0]
+ except IOError:
+ index = None
+
+ return index
+
+ def get_default_output_device(self):
+ try:
+ index = sounddevice.default.device[1]
+ except IOError:
+ index = None
+
+ return index
+
+ def get_input_devices(self):
+ devices = sounddevice.query_devices()
+
+ input_devices = [
+ device for device in devices if device["max_input_channels"] > 0
+ ]
+
+ if len(input_devices) == 0:
+ return []
+
+ try:
+ default_input_device = sounddevice.query_devices(kind="input")
+ except sounddevice.PortAudioError:
+ self.logger.exception("Failed to query the default input device")
+ default_input_device = None
+
+ input_devices = []
+ if default_input_device is not None:
+ default_input_device["index"] = devices.index(default_input_device)
+ input_devices += [default_input_device]
+
+ for device in devices:
+ if device["max_input_channels"] > 0:
+ device["index"] = devices.index(device)
+ if (
+ default_input_device is not None
+ and device["index"] != default_input_device["index"]
+ ):
+ input_devices += [device]
+
+ return input_devices
+
+ def get_output_devices(self):
+ devices = sounddevice.query_devices()
+
+ default_output_device = sounddevice.query_devices(kind="output")
+
+ output_devices = []
+ if default_output_device is not None:
+ default_output_device["index"] = devices.index(default_output_device)
+ output_devices += [default_output_device]
+
+ for device in devices:
+ if device["max_output_channels"] > 0:
+ device["index"] = devices.index(device)
+ if (
+ default_output_device is not None
+ and device["index"] != default_output_device["index"]
+ ):
+ output_devices += [device]
+
+ return output_devices
+
+ def select_input_device(self, index):
+ device = self.input_devices[index]
+
+ previous_stream = self.stream
+ previous_ringBuffer = self.ringBuffer
+ previous_action = self.action
+ previous_nchannels_max = self.nchannels_max
+ previous_device = self.device
+
+ self.logger.info("Trying to open input device #%d", index)
+
+ try:
+ (self.stream, self.ringBuffer, self.action, self.nchannels_max) = (
+ self.open_stream(device)
+ )
+ self.device = device
+ self.stream.start()
+ self.stream_start_time = self.stream.time
+ self.stream_read_index = 0
+ success = True
+ except Exception:
+ self.logger.exception("Failed to open input device")
+ success = False
+ if self.stream is not None:
+ self.stream.stop()
+ self.stream = previous_stream
+ self.ringBuffer = previous_ringBuffer
+ self.action = previous_action
+ self.nchannels_max = previous_nchannels_max
+ self.device = previous_device
+
+ if success:
+ self.logger.info("Success")
+
+ if previous_stream is not None:
+ previous_stream.stop()
+
+ self.first_channel = 0
+ nchannels = self.device["max_input_channels"]
+ if nchannels == 1:
+ self.second_channel = 0
+ else:
+ self.second_channel = 1
+
+ return success, self.input_devices.index(self.device)
+
+ def select_first_channel(self, index):
+ self.first_channel = index
+ success = True
+ return success, self.first_channel
+
+ def select_second_channel(self, index):
+ self.second_channel = index
+ success = True
+ return success, self.second_channel
+
+ def open_stream(self, device):
+ self.log_supported_input_formats(device)
+
+ self.logger.info("Opening the stream for device '%s'", device["name"])
+
+ stream = rtmixer.Recorder(
+ device=device["index"],
+ channels=device["max_input_channels"],
+ blocksize=FRAMES_PER_BUFFER,
+ samplerate=SAMPLING_RATE,
+ )
+
+ sampleSize = 4
+ nchannels_max = device["max_input_channels"]
+ elementSize = nchannels_max * sampleSize
+
+ ringbufferSeconds = 3.0
+ ringbufferSize = 2 ** int(math.log2(ringbufferSeconds * SAMPLING_RATE))
+
+ ringBuffer = rtmixer.RingBuffer(elementSize, ringbufferSize)
+
+ action = stream.record_ringbuffer(ringBuffer)
+
+ lat_ms = 1000 * stream.latency
+ self.logger.info("Device claims %d ms latency", lat_ms)
+
+ return (stream, ringBuffer, action, nchannels_max)
+
+ def log_supported_input_formats(self, device):
+ samplerates = [22050, 44100, 48000, 96000]
+ dtypes = [float32, int16, int8]
+ supported_formats = []
+ for samplerate in samplerates:
+ for dtype in dtypes:
+ try:
+ sounddevice.check_input_settings(
+ device=device["index"],
+ channels=device["max_input_channels"],
+ dtype=dtype,
+ extra_settings=None,
+ samplerate=samplerate,
+ )
+ supported_formats += [
+ f"{samplerate} Hz, {np.dtype(dtype).name}"
+ ]
+ except Exception:
+ pass
+
+ api = sounddevice.query_hostapis(device["hostapi"])["name"]
+ self.logger.info(
+ "Supported formats for '%s' on '%s': %s",
+ device["name"],
+ api,
+ supported_formats,
+ )
+
+ def open_output_stream(self, device, callback):
+ stream = sounddevice.OutputStream(
+ samplerate=SAMPLING_RATE,
+ blocksize=FRAMES_PER_BUFFER,
+ device=device["index"],
+ channels=device["max_output_channels"],
+ dtype=int16,
+ callback=callback,
+ )
+
+ return stream
+
+ def is_output_format_supported(self, device, output_format):
+ sounddevice.check_output_settings(
+ device=device["index"],
+ channels=device["max_output_channels"],
+ dtype=output_format,
+ samplerate=SAMPLING_RATE,
+ )
+
+ def get_readable_current_device(self):
+ return self.input_devices.index(self.device)
+
+ def get_readable_current_channels(self):
+ nchannels = self.device["max_input_channels"]
+
+ if nchannels == 2:
+ channels = ["L", "R"]
+ else:
+ channels = []
+ for channel in range(0, nchannels):
+ channels += ["%d" % channel]
+
+ return channels
+
+ def get_current_first_channel(self):
+ return self.first_channel
+
+ def get_current_second_channel(self):
+ return self.second_channel
+
+ def get_current_device_nchannels(self):
+ return self.device["max_input_channels"]
+
+ def get_device_outputchannels_count(self, device):
+ return device["max_output_channels"]
+
+ def fetchAudioData(self):
+ if self.action is None or self.ringBuffer is None:
+ return
+
+ while self.ringBuffer.read_available >= FRAMES_PER_BUFFER:
+ read, buf1, buf2 = self.ringBuffer.get_read_buffers(FRAMES_PER_BUFFER)
+ assert read == FRAMES_PER_BUFFER
+
+ stream_time = self.get_stream_time()
+
+ buffer1 = frombuffer(buf1, dtype="float32")
+ buffer2 = frombuffer(buf2, dtype="float32")
+ buffer = concatenate((buffer1, buffer2)).astype(float64)
+ buffer.shape = -1, self.nchannels_max
+ self.ringBuffer.advance_read_index(FRAMES_PER_BUFFER)
+
+ self.stream_read_index += read
+ stream_read_time = (
+ self.stream_start_time + self.stream_read_index / SAMPLING_RATE
+ )
+
+ if stream_read_time > stream_time and self.stream_read_index < 100000:
+ delta_seconds = stream_read_time - stream_time
+ self.stream_start_time -= delta_seconds
+
+ if (
+ stream_read_time
+ < stream_time - 100 * FRAMES_PER_BUFFER / SAMPLING_RATE
+ ):
+ self.logger.warning(
+ "Ringbuffer lagging behind: ringbuffer time = %f, stream time = %f",
+ stream_read_time,
+ stream_time,
+ )
+
+ channel = self.get_current_first_channel()
+ if self.duo_input:
+ channel_2 = self.get_current_second_channel()
+
+ floatdata1 = buffer[:, channel]
+
+ if self.duo_input:
+ floatdata2 = buffer[:, channel_2]
+ floatdata = vstack((floatdata1, floatdata2))
+ else:
+ floatdata = floatdata1
+ floatdata.shape = (1, floatdata.size)
+
+ input_overflows = self.action.stats.input_overflows
+ input_overflow = input_overflows > self.xruns
+ if input_overflow:
+ self.xruns = input_overflows
+ self.logger.info("Stream overflow!")
+ self.underflow.emit()
+
+ self.new_data_available.emit(floatdata, stream_read_time, input_overflow)
+
+ self.chunk_number += 1
+
+ def set_single_input(self):
+ self.duo_input = False
+
+ def set_duo_input(self):
+ self.duo_input = True
+
+ def get_stream_time(self) -> float:
+ if self.stream is None:
+ return 0
+
+ try:
+ return self.stream.time
+ except (sounddevice.PortAudioError, OSError):
+ if self.stream.device not in self.devices_with_timing_errors:
+ self.devices_with_timing_errors.append(self.stream.device)
+ self.logger.exception("Failed to read stream time")
+ return 0
+
+ def pause(self):
+ if self.stream is not None:
+ self.stream.stop()
+
+ def restart(self):
+ if self.stream is not None:
+ self.stream.start()
+ self.stream_start_time = self.stream.time
+ self.stream_read_index = 0
diff --git a/friture/ring_buffer_frame_reader.py b/friture/ring_buffer_frame_reader.py
new file mode 100644
index 00000000..a2386efa
--- /dev/null
+++ b/friture/ring_buffer_frame_reader.py
@@ -0,0 +1,60 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+
+# Copyright (C) 2024 Celeste Sinéad
+
+"""Read fixed-size analysis frames from the shared AudioBuffer ring."""
+
+from __future__ import annotations
+
+from math import floor
+from typing import Generator, Optional
+
+import numpy as np
+
+from friture.audiobuffer import AudioBuffer
+
+
+class RingBufferFrameReader:
+ """Track buffer offset and yield FFT-sized frames with fractional overlap.
+
+ Invariants (same as spectrum / spectrogram docks today):
+ - ``frame_size`` samples are read via ``AudioBuffer.data_indexed``.
+ - Advance ``frame_size * (1 - overlap)`` samples per frame.
+ - If the ring grows, unread position resets to the current write head.
+ """
+
+ def __init__(self, frame_size: int, overlap: float) -> None:
+ if not 0.0 <= overlap < 1.0:
+ raise ValueError("overlap must be in [0, 1)")
+ self.frame_size = frame_size
+ self.overlap = overlap
+ self._step = int(frame_size * (1.0 - overlap))
+ self._audiobuffer: Optional[AudioBuffer] = None
+ self._old_index = 0
+
+ def set_frame_size(self, frame_size: int) -> None:
+ self.frame_size = frame_size
+ self._step = int(frame_size * (1.0 - self.overlap))
+
+ def set_buffer(self, buffer: AudioBuffer) -> None:
+ self._audiobuffer = buffer
+ self._old_index = buffer.ringbuffer.offset
+
+ def iter_frames(self) -> Generator[np.ndarray, None, None]:
+ if self._audiobuffer is None or self._step <= 0:
+ return
+
+ index = self._audiobuffer.ringbuffer.offset
+ available = index - self._old_index
+
+ if available < 0:
+ available = 0
+ self._old_index = index
+
+ realizable = int(floor(available / self._step))
+
+ for _ in range(realizable):
+ frame = self._audiobuffer.data_indexed(self._old_index, self.frame_size)
+ yield frame
+ self._old_index += self._step
diff --git a/friture/scope.py b/friture/scope.py
index f82b094a..70d7d7ee 100644
--- a/friture/scope.py
+++ b/friture/scope.py
@@ -26,6 +26,7 @@
from friture.store import GetStore
from friture.audiobackend import SAMPLING_RATE
+from friture.settings_dialog_layout import create_form_layout
from friture.scope_data import Scope_Data
from friture.curve import Curve
@@ -169,7 +170,7 @@ def __init__(self, parent, view_model):
self.setWindowTitle("Scope settings")
- self.formLayout = QtWidgets.QFormLayout(self)
+ self.formLayout = create_form_layout(self)
self.doubleSpinBox_timerange = QtWidgets.QDoubleSpinBox(self)
self.doubleSpinBox_timerange.setDecimals(1)
@@ -181,8 +182,6 @@ def __init__(self, parent, view_model):
self.formLayout.addRow("Time range:", self.doubleSpinBox_timerange)
- self.setLayout(self.formLayout)
-
self.doubleSpinBox_timerange.valueChanged.connect(view_model.set_timerange)
# method
diff --git a/friture/settings.py b/friture/settings.py
index 5c84ec96..23b4022c 100644
--- a/friture/settings.py
+++ b/friture/settings.py
@@ -17,17 +17,29 @@
# You should have received a copy of the GNU General Public License
# along with Friture. If not, see .
-import sys
import logging
from PyQt5 import QtCore, QtWidgets
from PyQt5.QtCore import pyqtSignal, pyqtProperty
-from friture.audiobackend import AudioBackend
+
+from friture.input_device_catalog import (
+ InputDeviceCatalog,
+ apply_saved_input_selection,
+ get_input_device_catalog,
+)
+from friture.global_calibration import GlobalCalibrationService
from friture.main_toolbar_view_model import MainToolbarViewModel
from friture.ui_settings import Ui_Settings_Dialog
-
+from friture.calibration_settings_panel import CalibrationFormRows
+from friture.level_calibration import unit_label_for_calibration_target
+from friture.level_meter import (
+ calibration_quiet_message,
+ calibration_raw_rms_db,
+ calibration_signal_too_quiet,
+)
+
+# Backward-compatible re-exports for callers/tests.
no_input_device_title = "No audio input device found"
-
no_input_device_message = """No audio input device has been found.
Friture needs at least one input device. Please check your audio configuration.
@@ -36,81 +48,231 @@
"""
+def splash_enabled() -> bool:
+ settings = QtCore.QSettings("Friture", "Friture")
+ settings.beginGroup("AudioBackend")
+ enabled = settings.value("showSplash", True, type=bool)
+ settings.endGroup()
+ return enabled
+
+
class Settings_Dialog(QtWidgets.QDialog, Ui_Settings_Dialog):
show_playback_changed = pyqtSignal(bool)
history_length_changed = pyqtSignal(int)
- def __init__(self, parent, toolbar_view_model: MainToolbarViewModel):
+ def __init__(
+ self,
+ parent,
+ toolbar_view_model: MainToolbarViewModel,
+ catalog: InputDeviceCatalog | None = None,
+ global_calibration: GlobalCalibrationService | None = None,
+ raw_rms_provider=None,
+ ):
QtWidgets.QDialog.__init__(self, parent)
Ui_Settings_Dialog.__init__(self)
self.logger = logging.getLogger(__name__)
self._toolbar_view_model = toolbar_view_model
+ self._catalog = catalog or get_input_device_catalog()
+ self._global_calibration = global_calibration
+ self._raw_rms_provider = raw_rms_provider
- # Setup the user interface
self.setupUi(self)
- devices = AudioBackend().get_readable_devices_list()
+ self._setup_calibration_group()
+
+ devices = self._catalog.get_readable_devices_list()
+ self._has_input_devices = len(devices) > 0
+
+ if self._has_input_devices:
+ self._populate_input_devices(devices)
+ else:
+ self.inputGroup.setEnabled(False)
+ self.groupBox_second.setEnabled(False)
+
+ self.checkbox_showPlayback.stateChanged.connect(self.show_playback_checkbox_changed)
+ self.spinBox_historyLength.editingFinished.connect(self.history_length_edit_finished)
+ self.buttonBox.rejected.connect(self.close)
+
+ def _setup_calibration_group(self) -> None:
+ if self._global_calibration is None:
+ return
+
+ self.calibrationGroup = QtWidgets.QGroupBox("Input calibration")
+ calibration_layout = QtWidgets.QFormLayout(self.calibrationGroup)
+ self._calibration_rows = CalibrationFormRows(
+ calibration_layout, include_use_global=False
+ )
+ self._calibration_rows.button_calibrate.clicked.connect(
+ self._calibrate_global_from_current
+ )
+ self._calibration_rows.doubleSpinBox_offset.valueChanged.connect(
+ self._global_calibration.set_offset_db
+ )
+ self._calibration_rows.comboBox_unit.currentTextChanged.connect(
+ self._global_calibration.set_unit_label
+ )
+ self._calibration_rows.lineEdit_reference.textChanged.connect(
+ self._global_calibration.set_reference_note
+ )
+ self._global_calibration.changed.connect(self._sync_global_calibration_form)
+
+ self.lineEdit_micCalFile = QtWidgets.QLineEdit()
+ self.lineEdit_micCalFile.setReadOnly(True)
+ self.lineEdit_micCalFile.setPlaceholderText("No microphone cal file loaded")
+ mic_cal_buttons = QtWidgets.QHBoxLayout()
+ self.button_micCalBrowse = QtWidgets.QPushButton("Browse…")
+ self.button_micCalClear = QtWidgets.QPushButton("Clear")
+ mic_cal_buttons.addWidget(self.button_micCalBrowse)
+ mic_cal_buttons.addWidget(self.button_micCalClear)
+ mic_cal_buttons.addStretch(1)
+ calibration_layout.addRow("Mic cal file", self.lineEdit_micCalFile)
+ calibration_layout.addRow("", mic_cal_buttons)
+ self.label_micCalSummary = QtWidgets.QLabel("")
+ self.label_micCalSummary.setWordWrap(True)
+ calibration_layout.addRow("", self.label_micCalSummary)
+ self.button_micCalBrowse.clicked.connect(self._browse_mic_cal_file)
+ self.button_micCalClear.clicked.connect(self._clear_mic_cal_file)
+
+ startup_index = self.verticalLayout_5.indexOf(self.startupGroup)
+ self.verticalLayout_5.insertWidget(startup_index, self.calibrationGroup)
+ self._sync_global_calibration_form()
+
+ def _sync_global_calibration_form(self) -> None:
+ if self._global_calibration is None:
+ return
+ cal = self._global_calibration.calibration
+ self._calibration_rows.load(cal.offset_db, cal.unit_label, cal.reference_note)
+ self._sync_mic_cal_form()
+
+ def _sync_mic_cal_form(self) -> None:
+ if self._global_calibration is None:
+ return
+ path = self._global_calibration.mic_cal_file_path
+ self.lineEdit_micCalFile.setText(path)
+ mic_cal = self._global_calibration.mic_cal
+ if mic_cal is None:
+ self.label_micCalSummary.setText(
+ "Load a factory .txt or REW .cal file to apply frequency correction globally."
+ )
+ return
+ self.label_micCalSummary.setText(
+ f"{mic_cal.summary()}. Frequency correction applies to spectrum widgets; "
+ "use Calibrate… or offset for absolute SPL."
+ )
+
+ def _browse_mic_cal_file(self) -> None:
+ if self._global_calibration is None:
+ return
+ path, _selected = QtWidgets.QFileDialog.getOpenFileName(
+ self,
+ "Select microphone calibration file",
+ self._global_calibration.mic_cal_file_path or "",
+ "Calibration files (*.cal *.txt);;All files (*)",
+ )
+ if not path:
+ return
+ try:
+ self._global_calibration.set_mic_cal_file(path)
+ except Exception as exc:
+ QtWidgets.QMessageBox.warning(
+ self,
+ "Calibration file",
+ f"Could not load calibration file:\n{exc}",
+ )
+ return
+ self._sync_global_calibration_form()
- if devices == []:
- # no audio input device: display a message and exit
- QtWidgets.QMessageBox.critical(self, no_input_device_title, no_input_device_message)
- QtCore.QTimer.singleShot(0, self.exitOnInit)
- sys.exit(1)
+ def _clear_mic_cal_file(self) -> None:
+ if self._global_calibration is None:
return
+ self._global_calibration.clear_mic_cal_file()
+ self._sync_global_calibration_form()
+ def _calibrate_global_from_current(self) -> None:
+ if self._global_calibration is None or self._raw_rms_provider is None:
+ return
+ raw_rms_db = self._raw_rms_provider()
+ if calibration_signal_too_quiet(raw_rms_db):
+ cal = self._global_calibration.calibration
+ QtWidgets.QMessageBox.warning(
+ self,
+ "Calibrate input",
+ calibration_quiet_message(
+ raw_rms_db,
+ offset_db=cal.offset_db,
+ unit_label=cal.unit_label,
+ ),
+ )
+ return
+ target_db, ok = QtWidgets.QInputDialog.getDouble(
+ self,
+ "Calibrate input",
+ f"Raw input is {raw_rms_db:.1f} dBFS.\n"
+ "It should read (dB):",
+ value=94.0,
+ decimals=1,
+ )
+ if ok:
+ unit_label = unit_label_for_calibration_target(
+ self._global_calibration.calibration.unit_label, target_db
+ )
+ if unit_label != self._global_calibration.calibration.unit_label:
+ self._global_calibration.set_unit_label(unit_label)
+ self._global_calibration.calibrate_to_target(raw_rms_db, target_db)
+ self._sync_global_calibration_form()
+
+ def has_input_devices(self) -> bool:
+ return self._has_input_devices
+
+ def _populate_input_devices(self, devices: list[str]) -> None:
for device in devices:
self.comboBox_inputDevice.addItem(device)
- channels = AudioBackend().get_readable_current_channels()
+ channels = self._catalog.get_readable_current_channels()
for channel in channels:
self.comboBox_firstChannel.addItem(channel)
self.comboBox_secondChannel.addItem(channel)
- current_device = AudioBackend().get_readable_current_device()
+ current_device = self._catalog.get_readable_current_device()
self.comboBox_inputDevice.setCurrentIndex(current_device)
- first_channel = AudioBackend().get_current_first_channel()
+ first_channel = self._catalog.get_current_first_channel()
self.comboBox_firstChannel.setCurrentIndex(first_channel)
- second_channel = AudioBackend().get_current_second_channel()
+ second_channel = self._catalog.get_current_second_channel()
self.comboBox_secondChannel.setCurrentIndex(second_channel)
- # signals
self.comboBox_inputDevice.currentIndexChanged.connect(self.input_device_changed)
self.comboBox_firstChannel.activated.connect(self.first_channel_changed)
self.comboBox_secondChannel.activated.connect(self.second_channel_changed)
self.radioButton_single.toggled.connect(self.single_input_type_selected)
self.radioButton_duo.toggled.connect(self.duo_input_type_selected)
- self.checkbox_showPlayback.stateChanged.connect(self.show_playback_checkbox_changed)
- self.spinBox_historyLength.editingFinished.connect(self.history_length_edit_finished)
- @pyqtProperty(bool, notify=show_playback_changed) # type: ignore
+ @pyqtProperty(bool, notify=show_playback_changed) # type: ignore
def show_playback(self) -> bool:
return bool(self.checkbox_showPlayback.checkState())
- # slot
- # used when no audio input device has been found, to exit immediately
- def exitOnInit(self):
- QtWidgets.QApplication.instance().quit()
-
- # slot
def input_device_changed(self, index):
self._toolbar_view_model.recording = False
- success, index = AudioBackend().select_input_device(index)
+ success, index = self._catalog.select_input_device(index)
self.comboBox_inputDevice.setCurrentIndex(index)
if not success:
- # Note: the error message is a child of the settings dialog, so that
- # that dialog remains on top when the error message is closed
error_message = QtWidgets.QErrorMessage(self)
error_message.setWindowTitle("Input device error")
- error_message.showMessage("Impossible to use the selected input device, reverting to the previous one")
+ error_message.showMessage(
+ "Impossible to use the selected input device, reverting to the previous one"
+ )
+
+ self._sync_channel_combos()
+
+ self._toolbar_view_model.recording = True
- # reset the channels
- channels = AudioBackend().get_readable_current_channels()
+ def _sync_channel_combos(self) -> None:
+ channels = self._catalog.get_readable_current_channels()
self.comboBox_firstChannel.clear()
self.comboBox_secondChannel.clear()
@@ -119,94 +281,109 @@ def input_device_changed(self, index):
self.comboBox_firstChannel.addItem(channel)
self.comboBox_secondChannel.addItem(channel)
- first_channel = AudioBackend().get_current_first_channel()
- self.comboBox_firstChannel.setCurrentIndex(first_channel)
- second_channel = AudioBackend().get_current_second_channel()
- self.comboBox_secondChannel.setCurrentIndex(second_channel)
-
- self._toolbar_view_model.recording = True
+ self.comboBox_firstChannel.setCurrentIndex(
+ self._catalog.get_current_first_channel()
+ )
+ self.comboBox_secondChannel.setCurrentIndex(
+ self._catalog.get_current_second_channel()
+ )
- # slot
def first_channel_changed(self, index):
self._toolbar_view_model.recording = False
- success, index = AudioBackend().select_first_channel(index)
+ success, index = self._catalog.select_first_channel(index)
self.comboBox_firstChannel.setCurrentIndex(index)
if not success:
- # Note: the error message is a child of the settings dialog, so that
- # that dialog remains on top when the error message is closed
error_message = QtWidgets.QErrorMessage(self)
error_message.setWindowTitle("Input device error")
- error_message.showMessage("Impossible to use the selected channel as the first channel, reverting to the previous one")
+ error_message.showMessage(
+ "Impossible to use the selected channel as the first channel, reverting to the previous one"
+ )
self._toolbar_view_model.recording = True
- # slot
def second_channel_changed(self, index):
self._toolbar_view_model.recording = False
- success, index = AudioBackend().select_second_channel(index)
+ success, index = self._catalog.select_second_channel(index)
self.comboBox_secondChannel.setCurrentIndex(index)
if not success:
- # Note: the error message is a child of the settings dialog, so that
- # that dialog remains on top when the error message is closed
error_message = QtWidgets.QErrorMessage(self)
error_message.setWindowTitle("Input device error")
- error_message.showMessage("Impossible to use the selected channel as the second channel, reverting to the previous one")
+ error_message.showMessage(
+ "Impossible to use the selected channel as the second channel, reverting to the previous one"
+ )
self._toolbar_view_model.recording = True
- # slot
def single_input_type_selected(self, checked):
if checked:
self.groupBox_second.setEnabled(False)
- AudioBackend().set_single_input()
+ self._catalog.set_single_input()
self.logger.info("Switching to single input")
- # slot
def duo_input_type_selected(self, checked):
if checked:
self.groupBox_second.setEnabled(True)
- AudioBackend().set_duo_input()
+ self._catalog.set_duo_input()
self.logger.info("Switching to difference between two inputs")
- # slot
def show_playback_checkbox_changed(self, state: int) -> None:
self.show_playback_changed.emit(bool(state))
- # slot
def history_length_edit_finished(self) -> None:
self.history_length_changed.emit(self.spinBox_historyLength.value())
- # method
def saveState(self, settings):
- # for the input device, we search by name instead of index, since
- # we do not know if the device order stays the same between sessions
- settings.setValue("deviceName", self.comboBox_inputDevice.currentText())
- settings.setValue("firstChannel", self.comboBox_firstChannel.currentIndex())
- settings.setValue("secondChannel", self.comboBox_secondChannel.currentIndex())
- settings.setValue("duoInput", self.inputTypeButtonGroup.checkedId())
+ if self._has_input_devices:
+ settings.setValue("deviceName", self.comboBox_inputDevice.currentText())
+ settings.setValue("firstChannel", self.comboBox_firstChannel.currentIndex())
+ settings.setValue("secondChannel", self.comboBox_secondChannel.currentIndex())
+ settings.setValue("duoInput", self.inputTypeButtonGroup.checkedId())
settings.setValue("showPlayback", self.checkbox_showPlayback.checkState())
settings.setValue("historyLength", self.spinBox_historyLength.value())
+ settings.setValue("showSplash", self.checkbox_showSplash.isChecked())
+ if self._global_calibration is not None:
+ self._global_calibration.saveState(settings)
- # method
def restoreState(self, settings):
- device_name = settings.value("deviceName", "")
- device_index = self.comboBox_inputDevice.findText(device_name)
- # change the device only if it exists in the device list
- if device_index >= 0:
- self.comboBox_inputDevice.setCurrentIndex(device_index)
- channel = settings.value("firstChannel", 0, type=int)
- self.comboBox_firstChannel.setCurrentIndex(channel)
- channel = settings.value("secondChannel", 0, type=int)
- self.comboBox_secondChannel.setCurrentIndex(channel)
+ if self._has_input_devices:
+ device_name = settings.value("deviceName", "")
+ first_channel = settings.value("firstChannel", 0, type=int)
+ second_channel = settings.value("secondChannel", 0, type=int)
duo_input_id = settings.value("duoInput", 0, type=int)
- self.inputTypeButtonGroup.button(duo_input_id).setChecked(True)
+ duo_input = duo_input_id == self.inputTypeButtonGroup.id(
+ self.radioButton_duo
+ )
+
+ if apply_saved_input_selection(
+ self._catalog,
+ device_name,
+ first_channel,
+ second_channel,
+ duo_input,
+ ):
+ device_index = self.comboBox_inputDevice.findText(device_name)
+ self.comboBox_inputDevice.setCurrentIndex(device_index)
+ self._sync_channel_combos()
+ self.comboBox_firstChannel.setCurrentIndex(
+ self._catalog.get_current_first_channel()
+ )
+ self.comboBox_secondChannel.setCurrentIndex(
+ self._catalog.get_current_second_channel()
+ )
+ button = self.inputTypeButtonGroup.button(duo_input_id)
+ if button is not None:
+ button.setChecked(True)
+ self.groupBox_second.setEnabled(duo_input)
self.checkbox_showPlayback.setCheckState(settings.value("showPlayback", 0, type=int))
self.spinBox_historyLength.setValue(settings.value("historyLength", 30, type=int))
- # need to emit this because setValue doesn't emit editFinished
+ self.checkbox_showSplash.setChecked(settings.value("showSplash", True, type=bool))
self.history_length_changed.emit(self.spinBox_historyLength.value())
+ if self._global_calibration is not None:
+ self._global_calibration.restoreState(settings)
+ self._sync_global_calibration_form()
diff --git a/friture/settings_dialog_layout.py b/friture/settings_dialog_layout.py
new file mode 100644
index 00000000..ace694a3
--- /dev/null
+++ b/friture/settings_dialog_layout.py
@@ -0,0 +1,14 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+
+from PyQt5 import QtWidgets
+
+
+def create_form_layout(dialog: QtWidgets.QDialog) -> QtWidgets.QFormLayout:
+ form_layout = QtWidgets.QFormLayout()
+ main_layout = QtWidgets.QVBoxLayout(dialog)
+ main_layout.addLayout(form_layout)
+ button_box = QtWidgets.QDialogButtonBox(QtWidgets.QDialogButtonBox.Close, dialog)
+ main_layout.addWidget(button_box)
+ button_box.rejected.connect(dialog.close)
+ return form_layout
diff --git a/friture/spectrogram.py b/friture/spectrogram.py
index 3c72e505..c84b7316 100644
--- a/friture/spectrogram.py
+++ b/friture/spectrogram.py
@@ -21,6 +21,7 @@
from PyQt5.QtCore import QObject
from numpy import log10, floor, zeros, float64, tile, array, ndarray
+from friture.calibrated_display_range import CalibratedDisplayRangeMixin
from friture.audiobuffer import AudioBuffer
from friture.imageplot import ImagePlot
from friture.audioproc import audioproc
@@ -39,11 +40,13 @@
DEFAULT_WEIGHTING)
import friture.plotting.frequency_scales as fscales
+from friture.global_frequency_calibration import frequency_adjustment_db_for_owner
+
from friture.audiobackend import SAMPLING_RATE, FRAMES_PER_BUFFER, AudioBackend
from fractions import Fraction
-class Spectrogram_Widget(QObject):
+class Spectrogram_Widget(QObject, CalibratedDisplayRangeMixin):
def __init__(self, parent):
super().__init__(parent)
@@ -74,8 +77,6 @@ def __init__(self, parent):
self.minfreq = DEFAULT_MINFREQ
self.fft_size = 2 ** DEFAULT_FFT_SIZE * 32
self.proc.set_fftsize(self.fft_size)
- self.spec_min = DEFAULT_SPEC_MIN
- self.spec_max = DEFAULT_SPEC_MAX
self.weighting = DEFAULT_WEIGHTING
self.update_weighting()
@@ -93,7 +94,7 @@ def __init__(self, parent):
self.dT_s = self.fft_size * (1. - self.overlap) / float(SAMPLING_RATE)
self.PlotZoneImage.setfreqrange(self.minfreq, self.maxfreq)
- self.PlotZoneImage.setspecrange(self.spec_min, self.spec_max)
+ self.init_calibrated_display_range(parent, DEFAULT_SPEC_MIN, DEFAULT_SPEC_MAX)
self.PlotZoneImage.setweighting(self.weighting)
self.PlotZoneImage.settimerange(self.timerange_s, self.dT_s)
self.update_jitter()
@@ -159,7 +160,9 @@ def handle_new_data(self, floatdata: ndarray) -> None:
self.old_index += int(needed)
w = tile(self.w, (1, realizable))
- norm_spectrogram = self.scale_spectrogram(self.log_spectrogram(spn) + w)
+ raw_db = self.log_spectrogram(spn) + w
+ adjustment = frequency_adjustment_db_for_owner(self, self.freq).reshape(-1, 1)
+ norm_spectrogram = self.scale_spectrogram(raw_db + adjustment)
self.screen_resampler.set_height(self.PlotZoneImage.spectrogram_screen_height())
screen_rate_frac = Fraction(max(self.PlotZoneImage.spectrogram_screen_width(), 1), int(self.timerange_s * 1000))
@@ -234,12 +237,13 @@ def setfftsize(self, fft_size):
self.update_jitter()
def setmin(self, value):
- self.spec_min = value
- self.PlotZoneImage.setspecrange(self.spec_min, self.spec_max)
+ self.set_base_spec_min(value)
def setmax(self, value):
- self.spec_max = value
- self.PlotZoneImage.setspecrange(self.spec_min, self.spec_max)
+ self.set_base_spec_max(value)
+
+ def _apply_calibrated_spec_range(self, spec_min: float, spec_max: float) -> None:
+ self.PlotZoneImage.setspecrange(spec_min, spec_max)
def setweighting(self, weighting):
self.weighting = weighting
diff --git a/friture/spectrogram_settings.py b/friture/spectrogram_settings.py
index 03830ce4..a77110d8 100644
--- a/friture/spectrogram_settings.py
+++ b/friture/spectrogram_settings.py
@@ -21,6 +21,7 @@
from PyQt5 import QtWidgets
from friture.audiobackend import SAMPLING_RATE
+from friture.settings_dialog_layout import create_form_layout
import friture.plotting.frequency_scales as fscales
# shared with spectrogram.py
@@ -45,7 +46,7 @@ def __init__(self, parent, view_model):
self.setWindowTitle("Spectrogram settings")
- self.formLayout = QtWidgets.QFormLayout(self)
+ self.formLayout = create_form_layout(self)
self.doubleSpinBox_timerange = QtWidgets.QDoubleSpinBox(self)
self.doubleSpinBox_timerange.setDecimals(1)
@@ -124,8 +125,6 @@ def __init__(self, parent, view_model):
self.formLayout.addRow("Max color:", self.spinBox_specmax)
self.formLayout.addRow("Middle-ear weighting:", self.comboBox_weighting)
- self.setLayout(self.formLayout)
-
self.comboBox_fftsize.currentIndexChanged.connect(self.fftsizechanged)
self.comboBox_freqscale.currentIndexChanged.connect(self.freqscalechanged)
self.spinBox_minfreq.valueChanged.connect(view_model.setminfreq)
diff --git a/friture/spectrum.py b/friture/spectrum.py
index 1722d31b..9ba329b0 100644
--- a/friture/spectrum.py
+++ b/friture/spectrum.py
@@ -1,7 +1,7 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
-# Copyright (C) 2009 Timoth?Lecomte
+# Copyright (C) 2009 Timothée Lecomte
# This file is part of Friture.
#
@@ -18,8 +18,7 @@
# along with Friture. If not, see .
from PyQt5.QtCore import QObject
-from numpy import log10, argmax, zeros, arange, floor, float64, ones
-from friture.audioproc import audioproc # audio processing class
+from friture.calibrated_display_range import CalibratedDisplayRangeMixin
from friture.spectrum_settings import (Spectrum_Settings_Dialog, # settings dialog
DEFAULT_FFT_SIZE,
DEFAULT_FREQ_SCALE,
@@ -32,12 +31,13 @@
DEFAULT_SHOW_FREQ_LABELS)
import friture.plotting.frequency_scales as fscales
-from friture.audiobackend import SAMPLING_RATE
+from friture.ring_buffer_frame_reader import RingBufferFrameReader
from friture.spectrumPlotWidget import SpectrumPlotWidget
-from friture_extensions.exp_smoothing_conv import pyx_exp_smoothed_value_numpy
+from friture.spectrum_frame_analyzer import SpectrumFrameAnalyzer
+from friture.global_frequency_calibration import frequency_adjustment_db_for_owner
-class Spectrum_Widget(QObject):
+class Spectrum_Widget(QObject, CalibratedDisplayRangeMixin):
def __init__(self, parent):
super().__init__(parent)
@@ -45,35 +45,25 @@ def __init__(self, parent):
self.audiobuffer = None
self.PlotZoneSpect = SpectrumPlotWidget(self)
- # initialize the class instance that will do the fft
- self.proc = audioproc()
-
- self.maxfreq = DEFAULT_MAXFREQ
- self.proc.set_maxfreq(self.maxfreq)
+ fft_size = 2 ** DEFAULT_FFT_SIZE * 32
self.minfreq = DEFAULT_MINFREQ
- self.fft_size = 2 ** DEFAULT_FFT_SIZE * 32
- self.proc.set_fftsize(self.fft_size)
- self.spec_min = DEFAULT_SPEC_MIN
- self.spec_max = DEFAULT_SPEC_MAX
- self.weighting = DEFAULT_WEIGHTING
- self.dual_channels = False
- self.response_time = DEFAULT_RESPONSE_TIME
-
- self.update_weighting()
- self.freq = self.proc.get_freq_scale()
-
- self.old_index = 0
- self.overlap = 3. / 4.
-
- self.update_display_buffers()
+ self.maxfreq = DEFAULT_MAXFREQ
- # set kernel and parameters for the smoothing filter
- self.setresponsetime(self.response_time)
+ self.overlap = 3. / 4.
+ self._frame_reader = RingBufferFrameReader(fft_size, self.overlap)
+ self._analyzer = SpectrumFrameAnalyzer(
+ fft_size=fft_size,
+ overlap=self.overlap,
+ minfreq=self.minfreq,
+ maxfreq=self.maxfreq,
+ weighting=DEFAULT_WEIGHTING,
+ response_time=DEFAULT_RESPONSE_TIME,
+ )
self.PlotZoneSpect.setfreqscale(fscales.Mel) # matches DEFAULT_FREQ_SCALE = 2 #Mel
self.PlotZoneSpect.setfreqrange(self.minfreq, self.maxfreq)
- self.PlotZoneSpect.setspecrange(self.spec_min, self.spec_max)
- self.PlotZoneSpect.setweighting(self.weighting)
+ self.init_calibrated_display_range(parent, DEFAULT_SPEC_MIN, DEFAULT_SPEC_MAX)
+ self.PlotZoneSpect.setweighting(DEFAULT_WEIGHTING)
self.PlotZoneSpect.set_peaks_enabled(True)
self.PlotZoneSpect.set_baseline_displayUnits(0.)
self.PlotZoneSpect.setShowFreqLabel(DEFAULT_SHOW_FREQ_LABELS)
@@ -81,109 +71,64 @@ def __init__(self, parent):
# initialize the settings dialog
self.settings_dialog = Spectrum_Settings_Dialog(parent, self)
- def qml_file_name(self):
- return self.PlotZoneSpect.qml_file_name()
-
- def view_model(self):
- return self.PlotZoneSpect.view_model()
-
- # method
- def set_buffer(self, buffer):
- self.audiobuffer = buffer
- self.old_index = self.audiobuffer.ringbuffer.offset
-
- def log_spectrogram(self, sp):
- # Note: implementing the log10 of the array in Cython did not bring
- # any speedup.
- # Idea: Instead of computing the log of the data, I could pre-compute
- # a list of values associated with the colormap, and then do a search...
- epsilon = 1e-30
- return 10. * log10(sp + epsilon)
-
- def harmonic_product_spectrum(self, sp):
- # Chose 3 harmonics for no particularly good reason; initial results
- # for pitch detection are good.
- product_count = 3
-
- harmonic_length = sp.shape[0] // product_count
-
- # common case: product_count == 3 — multiply the three slices
- # directly. This avoids allocating a temporary ones array and still
- # doesn't make copies of the original `sp` beyond NumPy's routine
- # temporary results for the elementwise multiplication.
- if product_count == 3:
- # Downsample and multiply
- res = (sp[:harmonic_length]
- * sp[::2][:harmonic_length]
- * sp[::3][:harmonic_length])
- else:
- res = ones(harmonic_length, dtype=sp.dtype)
- for i in range(1, product_count + 1):
- res *= sp[::i][:harmonic_length]
- return res
-
- def handle_new_data(self, floatdata):
- # we need to maintain an index of where we are in the buffer
- index = self.audiobuffer.ringbuffer.offset
-
- available = index - self.old_index
-
- if available < 0:
- # ringbuffer must have grown or something...
- available = 0
- self.old_index = index
-
- # if we have enough data to add a frequency column in the time-frequency plane, compute it
- needed = self.fft_size * (1. - self.overlap)
- realizable = int(floor(available / needed))
+ @property
+ def proc(self):
+ return self._analyzer.proc
- if realizable > 0:
- sp1n = zeros((len(self.freq), realizable), dtype=float64)
- sp2n = zeros((len(self.freq), realizable), dtype=float64)
+ @property
+ def fft_size(self):
+ return self._analyzer.fft_size
- for i in range(realizable):
- floatdata = self.audiobuffer.data_indexed(self.old_index, self.fft_size)
+ @property
+ def freq(self):
+ return self._analyzer.freq
- # first channel
- # FFT transform
- sp1n[:, i] = self.proc.analyzelive(floatdata[0, :])
+ @property
+ def dual_channels(self):
+ return self._analyzer.dual_channels
- if self.dual_channels and floatdata.shape[0] > 1:
- # second channel for comparison
- sp2n[:, i] = self.proc.analyzelive(floatdata[1, :])
+ @dual_channels.setter
+ def dual_channels(self, value):
+ self._analyzer.set_dual_channels(value)
- self.old_index += int(needed)
+ @property
+ def weighting(self):
+ return self._analyzer.weighting
- # compute the widget data
- sp1 = pyx_exp_smoothed_value_numpy(self.kernel, self.alpha, sp1n, self.dispbuffers1)
- sp2 = pyx_exp_smoothed_value_numpy(self.kernel, self.alpha, sp2n, self.dispbuffers2)
- # store result for next computation
- self.dispbuffers1 = sp1
- self.dispbuffers2 = sp2
+ @weighting.setter
+ def weighting(self, value):
+ self._analyzer.set_weighting(value)
- sp1.shape = self.freq.shape
- sp2.shape = self.freq.shape
- self.w.shape = self.freq.shape
+ @property
+ def response_time(self):
+ return self._analyzer.response_time
- if self.dual_channels and floatdata.shape[0] > 1:
- dB_spectrogram = self.log_spectrogram(sp2) - self.log_spectrogram(sp1)
- else:
- dB_spectrogram = self.log_spectrogram(sp1) + self.w
+ @response_time.setter
+ def response_time(self, value):
+ self._analyzer.set_response_time(value)
- # the log operation and the weighting could be deffered
- # to the post-weedening !
- i = argmax(dB_spectrogram)
- fmax = self.freq[i]
+ def qml_file_name(self):
+ return self.PlotZoneSpect.qml_file_name()
+
+ def view_model(self):
+ return self.PlotZoneSpect.view_model()
- # The maximum value in the harmonic product spectrum is quite
- # likely to correspond to a fundamental frequency.
- harmonic_products = self.harmonic_product_spectrum(sp1)
- pitch_idx = argmax(harmonic_products)
- fpitch = max(self.freq[pitch_idx], 1e-20)
+ def set_buffer(self, buffer):
+ self.audiobuffer = buffer
+ self._frame_reader.set_buffer(buffer)
- self.PlotZoneSpect.setdata(self.freq, dB_spectrogram, fmax, fpitch)
+ def handle_new_data(self, floatdata):
+ del floatdata
+ result = self._analyzer.process_frames(list(self._frame_reader.iter_frames()))
+ if result is not None:
+ adjustment = frequency_adjustment_db_for_owner(self, result.freq)
+ self.PlotZoneSpect.setdata(
+ result.freq,
+ result.db_spectrogram + adjustment,
+ result.fmax_hz,
+ result.fpitch_hz,
+ )
- # method
def canvasUpdate(self):
self.PlotZoneSpect.canvasUpdate()
@@ -194,37 +139,8 @@ def restart(self):
self.PlotZoneSpect.restart()
def setresponsetime(self, response_time):
- # time = SMOOTH_DISPLAY_TIMER_PERIOD_MS/1000. #DISPLAY
- # time = 0.025 #IMPULSE setting for a sound level meter
- # time = 0.125 #FAST setting for a sound level meter
- # time = 1. #SLOW setting for a sound level meter
self.response_time = response_time
- # an exponential smoothing filter is a simple IIR filter
- # y_i = a*x_i + (1-a)*y_{i-1}
- #
- # we can unroll the recurrence a bit:
- # y_{i+1} = a*x_{i+1} + (1-a)*y_i
- # = a*x_{i+1} + (1-a)*a*x_i + (1-a)^2*y_{i-1}
- # y_{i+2} = a*x_{i+2} + (1-a)*y_{i+1}
- # = a*x_{i+2} + (1-a)*a*x_{i+1} + (1-a)^2*a*x_i + (1-a)^3*y_{i-1}
- # ...
- # we compute alpha so that the N most recent samples represent 100*w percent of the output
- w = 0.65
- delta_n = self.fft_size * (1. - self.overlap)
- n = self.response_time * SAMPLING_RATE / delta_n
- N = 2 * 4096
- self.alpha = 1. - (1. - w) ** (1. / (n + 1))
- self.kernel = self.compute_kernel(self.alpha, N)
-
- def compute_kernel(self, alpha, N):
- kernel = (1. - alpha) ** arange(N - 1, -1, -1)
- return kernel
-
- def update_display_buffers(self):
- self.dispbuffers1 = zeros(len(self.freq))
- self.dispbuffers2 = zeros(len(self.freq))
-
def setminfreq(self, minfreq):
self.setMinMaxFreq(minfreq, self.maxfreq)
@@ -238,50 +154,27 @@ def setMinMaxFreq(self, minfreq, maxfreq):
realmin = min(self.minfreq, self.maxfreq)
realmax = max(self.minfreq, self.maxfreq)
- self.proc.set_maxfreq(realmax)
-
- self.freq = self.proc.get_freq_scale()
- self.update_display_buffers()
- self.update_weighting()
- # reset kernel and parameters for the smoothing filter
- self.setresponsetime(self.response_time)
-
+ self._analyzer.set_freq_range(minfreq, maxfreq)
self.PlotZoneSpect.setfreqrange(realmin, realmax)
def setfftsize(self, fft_size):
- self.fft_size = fft_size
- self.proc.set_fftsize(self.fft_size)
- self.freq = self.proc.get_freq_scale()
- self.update_display_buffers()
- self.update_weighting()
- # reset kernel and parameters for the smoothing filter
- self.setresponsetime(self.response_time)
+ self._analyzer.set_fft_size(fft_size)
+ self._frame_reader.set_frame_size(fft_size)
+ if self.audiobuffer is not None:
+ self._frame_reader.set_buffer(self.audiobuffer)
def setmin(self, value):
- self.spec_min = value
- self.PlotZoneSpect.setspecrange(self.spec_min, self.spec_max)
+ self.set_base_spec_min(value)
def setmax(self, value):
- self.spec_max = value
- self.PlotZoneSpect.setspecrange(self.spec_min, self.spec_max)
+ self.set_base_spec_max(value)
+
+ def _apply_calibrated_spec_range(self, spec_min: float, spec_max: float) -> None:
+ self.PlotZoneSpect.setspecrange(spec_min, spec_max)
def setweighting(self, weighting):
self.weighting = weighting
self.PlotZoneSpect.setweighting(weighting)
- self.update_weighting()
-
- def update_weighting(self):
- A, B, C = self.proc.get_freq_weighting()
- if self.weighting == 0:
- self.w = zeros(A.shape)
- elif self.weighting == 1:
- self.w = A
- elif self.weighting == 2:
- self.w = B
- else:
- self.w = C
-
- self.w.shape = (1, self.w.size)
def setdualchannels(self, dual_enabled):
self.dual_channels = dual_enabled
diff --git a/friture/spectrum_frame_analyzer.py b/friture/spectrum_frame_analyzer.py
new file mode 100644
index 00000000..419af61b
--- /dev/null
+++ b/friture/spectrum_frame_analyzer.py
@@ -0,0 +1,191 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+
+# Copyright (C) 2024 Celeste Sinéad
+
+"""Pure-numpy spectrum analysis for dock widgets (no Qt)."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from typing import Optional, Sequence
+
+import numpy as np
+from numpy import arange, argmax, float64, log10, ones, zeros
+
+from friture.audiobackend import SAMPLING_RATE
+from friture.audioproc import audioproc
+from friture.spectrum_settings import (
+ DEFAULT_MAXFREQ,
+ DEFAULT_MINFREQ,
+ DEFAULT_RESPONSE_TIME,
+ DEFAULT_WEIGHTING,
+)
+from friture_extensions.exp_smoothing_conv import pyx_exp_smoothed_value_numpy
+
+
+@dataclass(frozen=True)
+class SpectrumFrameResult:
+ freq: np.ndarray
+ db_spectrogram: np.ndarray
+ fmax_hz: float
+ fpitch_hz: float
+
+
+class SpectrumFrameAnalyzer:
+ """FFT spectrum + smoothing + peak pick from ring-buffer frames."""
+
+ def __init__(
+ self,
+ fft_size: int,
+ overlap: float = 0.75,
+ minfreq: float = DEFAULT_MINFREQ,
+ maxfreq: float = DEFAULT_MAXFREQ,
+ weighting: int = DEFAULT_WEIGHTING,
+ response_time: float = DEFAULT_RESPONSE_TIME,
+ dual_channels: bool = False,
+ ) -> None:
+ self.overlap = overlap
+ self.fft_size = fft_size
+ self.minfreq = minfreq
+ self.maxfreq = maxfreq
+ self.weighting = weighting
+ self.response_time = response_time
+ self.dual_channels = dual_channels
+
+ self._proc = audioproc()
+ self._proc.set_maxfreq(max(self.minfreq, self.maxfreq))
+ self._proc.set_fftsize(self.fft_size)
+
+ self.freq = self._proc.get_freq_scale()
+ self._w = zeros(self.freq.shape)
+ self._update_weighting()
+
+ self._dispbuffers1 = zeros(len(self.freq))
+ self._dispbuffers2 = zeros(len(self.freq))
+ self._set_response_time(self.response_time)
+
+ @property
+ def proc(self) -> audioproc:
+ return self._proc
+
+ def set_fft_size(self, fft_size: int) -> None:
+ self.fft_size = fft_size
+ self._proc.set_fftsize(fft_size)
+ self.freq = self._proc.get_freq_scale()
+ self._reset_display_buffers()
+ self._update_weighting()
+ self._set_response_time(self.response_time)
+
+ def set_freq_range(self, minfreq: float, maxfreq: float) -> None:
+ self.minfreq = minfreq
+ self.maxfreq = maxfreq
+ self._proc.set_maxfreq(max(minfreq, maxfreq))
+ self.freq = self._proc.get_freq_scale()
+ self._reset_display_buffers()
+ self._update_weighting()
+ self._set_response_time(self.response_time)
+
+ def set_weighting(self, weighting: int) -> None:
+ self.weighting = weighting
+ self._update_weighting()
+
+ def set_response_time(self, response_time: float) -> None:
+ self.response_time = response_time
+ self._set_response_time(response_time)
+
+ def set_dual_channels(self, dual_channels: bool) -> None:
+ self.dual_channels = dual_channels
+
+ def process_frames(
+ self, frames: Sequence[np.ndarray]
+ ) -> Optional[SpectrumFrameResult]:
+ realizable = len(frames)
+ if realizable == 0:
+ return None
+
+ sp1n = zeros((len(self.freq), realizable), dtype=float64)
+ sp2n = zeros((len(self.freq), realizable), dtype=float64)
+
+ last_frame = frames[-1]
+ for i, frame in enumerate(frames):
+ sp1n[:, i] = self._proc.analyzelive(frame[0, :])
+ if self.dual_channels and frame.shape[0] > 1:
+ sp2n[:, i] = self._proc.analyzelive(frame[1, :])
+
+ sp1 = pyx_exp_smoothed_value_numpy(
+ self._kernel, self._alpha, sp1n, self._dispbuffers1
+ )
+ sp2 = pyx_exp_smoothed_value_numpy(
+ self._kernel, self._alpha, sp2n, self._dispbuffers2
+ )
+ self._dispbuffers1 = sp1
+ self._dispbuffers2 = sp2
+
+ sp1.shape = self.freq.shape
+ sp2.shape = self.freq.shape
+ w = self._w.reshape(self.freq.shape)
+
+ if self.dual_channels and last_frame.shape[0] > 1:
+ db_spectrogram = self._log_spectrogram(sp2) - self._log_spectrogram(sp1)
+ else:
+ db_spectrogram = self._log_spectrogram(sp1) + w
+
+ peak_index = argmax(db_spectrogram)
+ fmax_hz = float(self.freq[peak_index])
+
+ harmonic_products = self._harmonic_product_spectrum(sp1)
+ pitch_idx = argmax(harmonic_products)
+ fpitch_hz = float(max(self.freq[pitch_idx], 1e-20))
+
+ return SpectrumFrameResult(
+ freq=self.freq,
+ db_spectrogram=db_spectrogram,
+ fmax_hz=fmax_hz,
+ fpitch_hz=fpitch_hz,
+ )
+
+ def _reset_display_buffers(self) -> None:
+ self._dispbuffers1 = zeros(len(self.freq))
+ self._dispbuffers2 = zeros(len(self.freq))
+
+ def _set_response_time(self, response_time: float) -> None:
+ w = 0.65
+ delta_n = self.fft_size * (1.0 - self.overlap)
+ n = response_time * SAMPLING_RATE / delta_n
+ n_kernel = 2 * 4096
+ self._alpha = 1.0 - (1.0 - w) ** (1.0 / (n + 1))
+ self._kernel = (1.0 - self._alpha) ** arange(n_kernel - 1, -1, -1)
+
+ def _update_weighting(self) -> None:
+ a_weight, b_weight, c_weight = self._proc.get_freq_weighting()
+ if self.weighting == 0:
+ self._w = zeros(a_weight.shape)
+ elif self.weighting == 1:
+ self._w = a_weight
+ elif self.weighting == 2:
+ self._w = b_weight
+ else:
+ self._w = c_weight
+
+ @staticmethod
+ def _log_spectrogram(sp: np.ndarray) -> np.ndarray:
+ epsilon = 1e-30
+ return 10.0 * log10(sp + epsilon)
+
+ @staticmethod
+ def _harmonic_product_spectrum(sp: np.ndarray) -> np.ndarray:
+ product_count = 3
+ harmonic_length = sp.shape[0] // product_count
+
+ if product_count == 3:
+ return (
+ sp[:harmonic_length]
+ * sp[::2][:harmonic_length]
+ * sp[::3][:harmonic_length]
+ )
+
+ res = ones(harmonic_length, dtype=sp.dtype)
+ for i in range(1, product_count + 1):
+ res *= sp[::i][:harmonic_length]
+ return res
diff --git a/friture/spectrum_settings.py b/friture/spectrum_settings.py
index 5d326dbd..429dd989 100644
--- a/friture/spectrum_settings.py
+++ b/friture/spectrum_settings.py
@@ -21,6 +21,7 @@
from PyQt5 import QtWidgets
from friture.audiobackend import SAMPLING_RATE
+from friture.settings_dialog_layout import create_form_layout
import friture.plotting.frequency_scales as fscales
# shared with spectrum_settings.py
@@ -48,7 +49,7 @@ def __init__(self, parent, view_model):
self.setWindowTitle("Spectrum settings")
- self.formLayout = QtWidgets.QFormLayout(self)
+ self.formLayout = create_form_layout(self)
self.comboBox_dual_channel = QtWidgets.QComboBox(self)
self.comboBox_dual_channel.setObjectName("dual")
@@ -145,8 +146,6 @@ def __init__(self, parent, view_model):
self.formLayout.addRow("Display max-frequency label:", self.checkBox_showFreqLabels)
self.formLayout.addRow("Display pitch label:", self.checkBox_showPitchLabel)
- self.setLayout(self.formLayout)
-
self.comboBox_dual_channel.currentIndexChanged.connect(self.dualchannelchanged)
self.comboBox_fftsize.currentIndexChanged.connect(self.fftsizechanged)
self.comboBox_freqscale.currentIndexChanged.connect(self.freqscalechanged)
diff --git a/friture/test/fixtures/mic_cal_factory.txt b/friture/test/fixtures/mic_cal_factory.txt
new file mode 100644
index 00000000..2188df2d
--- /dev/null
+++ b/friture/test/fixtures/mic_cal_factory.txt
@@ -0,0 +1,6 @@
+*1000Hz -38.6
+
+20.00 0.1
+1000.00 0.0
+10000.00 2.2
+20000.00 2.5
diff --git a/friture/test/fixtures/mic_cal_rew.cal b/friture/test/fixtures/mic_cal_rew.cal
new file mode 100644
index 00000000..8da8a944
--- /dev/null
+++ b/friture/test/fixtures/mic_cal_rew.cal
@@ -0,0 +1,4 @@
+Sensitivity -12.34 dBFS
+20 -0.5
+1000 0.0
+10000 1.5
diff --git a/friture/test/helpers.py b/friture/test/helpers.py
new file mode 100644
index 00000000..d9494e94
--- /dev/null
+++ b/friture/test/helpers.py
@@ -0,0 +1,86 @@
+# -*- coding: utf-8 -*-
+
+# Copyright (C) 2024 Celeste Sinéad
+
+"""Shared helpers for Friture integration tests (not a test module).
+
+Use ``AudioHarness`` to push synthetic audio through ``AudioBuffer`` the same
+way live ingest does. Use ``wire_dock_analysis_widget`` to connect a widget
+with the same signals ``Dock`` uses in production.
+"""
+
+import os
+import tempfile
+
+import numpy as np
+from PyQt5.QtCore import QSettings
+from PyQt5.QtWidgets import QApplication, QWidget
+
+from friture.audiobackend import SAMPLING_RATE
+from friture.audiobuffer import AudioBuffer
+from friture.global_calibration import GlobalCalibrationService
+
+
+def attach_global_calibration(parent: QWidget) -> GlobalCalibrationService:
+ service = GlobalCalibrationService(parent)
+ parent.global_calibration = service
+ return service
+
+
+def wire_dock_analysis_widget(widget, buffer: AudioBuffer) -> None:
+ """Mirror ``Dock.widget_select`` audio wiring for tests."""
+ widget.set_buffer(buffer)
+ buffer.new_data_available.connect(widget.handle_new_data)
+
+
+def ensure_qapplication() -> QApplication:
+ os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
+ return QApplication.instance() or QApplication([])
+
+
+def make_parent_widget() -> QWidget:
+ ensure_qapplication()
+ return QWidget()
+
+
+class IsolatedQSettings:
+ """Use a temp directory so tests never touch the user's Friture config."""
+
+ def __init__(self) -> None:
+ settings_dir = tempfile.mkdtemp(prefix="friture-qsettings-")
+ QSettings.setPath(QSettings.IniFormat, QSettings.UserScope, settings_dir)
+ self.settings = QSettings("Friture", "Friture")
+ self.settings.clear()
+
+
+class AudioHarness:
+ """Push synthetic audio through the same buffer path as the live app."""
+
+ def __init__(self) -> None:
+ self.buffer = AudioBuffer()
+
+ def push(self, samples: np.ndarray, sample_offset: int | None = None) -> np.ndarray:
+ if sample_offset is None:
+ sample_offset = self.buffer.ringbuffer.offset + samples.shape[1]
+ self.buffer.handle_new_data(
+ samples,
+ sample_offset / SAMPLING_RATE,
+ None,
+ )
+ return samples
+
+ def push_silence(self, num_samples: int) -> np.ndarray:
+ return self.push(np.zeros((1, num_samples)))
+
+ def push_sine(
+ self,
+ frequency_hz: float,
+ num_samples: int,
+ amplitude: float = 1.0,
+ ) -> np.ndarray:
+ time = np.linspace(0, num_samples / SAMPLING_RATE, num_samples, endpoint=False)
+ samples = np.array([amplitude * np.sin(2 * np.pi * frequency_hz * time)])
+ return self.push(samples)
+
+ def latest_chunk(self) -> np.ndarray:
+ return self.buffer.newdata()
diff --git a/friture/test/runner.py b/friture/test/runner.py
index 04f51436..61b9755d 100755
--- a/friture/test/runner.py
+++ b/friture/test/runner.py
@@ -27,5 +27,6 @@
logging.basicConfig(level=logging.WARNING)
np.set_printoptions(threshold=1024)
loader = unittest.TestLoader()
- suite = loader.discover(os.path.dirname(__file__), '*.py')
- unittest.TextTestRunner(verbosity=2).run(suite)
+ suite = loader.discover(os.path.dirname(__file__), pattern="test_*.py")
+ result = unittest.TextTestRunner(verbosity=2).run(suite)
+ raise SystemExit(0 if result.wasSuccessful() else 1)
diff --git a/friture/test/test_IECScale.py b/friture/test/test_IECScale.py
deleted file mode 100644
index 813e6ec1..00000000
--- a/friture/test/test_IECScale.py
+++ /dev/null
@@ -1,18 +0,0 @@
-if __name__ == '__main__':
- import numpy as np
- import matplotlib.pyplot as plt
-
- import sys
- sys.path.insert(0, '.')
-
- import friture.qsynthmeter as meter # type: ignore
-
- scale = meter.IECScale()
-
- x = np.linspace(-100, 10, 1000)
- y = [scale.iec_scale(x0) for x0 in x]
-
- plt.figure()
- plt.plot(x, y)
-
- plt.show()
diff --git a/friture/test/test_app_bootstrap.py b/friture/test/test_app_bootstrap.py
new file mode 100644
index 00000000..30011529
--- /dev/null
+++ b/friture/test/test_app_bootstrap.py
@@ -0,0 +1,66 @@
+# -*- coding: utf-8 -*-
+
+# Copyright (C) 2024 Celeste Sinéad
+
+import logging
+import os
+import unittest
+
+from PyQt5.QtWidgets import QApplication
+
+from friture.analyzer import _linux_apply_dark_palette
+from friture.main_toolbar_view_model import MainToolbarViewModel
+from friture.test.helpers import IsolatedQSettings, ensure_qapplication
+
+
+class AnalyzerBootstrapTest(unittest.TestCase):
+ def test_dark_palette_applies_on_linux(self) -> None:
+ ensure_qapplication()
+ app = QApplication.instance()
+ assert app is not None
+
+ _linux_apply_dark_palette(app, logging.getLogger("test"))
+
+ self.assertEqual(app.palette().color(app.palette().Window).name(), "#141415")
+
+ def test_dark_palette_skipped_when_light_theme_forced(self) -> None:
+ ensure_qapplication()
+ app = QApplication.instance()
+ assert app is not None
+ original = app.palette().color(app.palette().Window)
+
+ os.environ["FRITURE_LIGHT_THEME"] = "1"
+ try:
+ _linux_apply_dark_palette(app, logging.getLogger("test"))
+ finally:
+ del os.environ["FRITURE_LIGHT_THEME"]
+
+ self.assertEqual(app.palette().color(app.palette().Window), original)
+
+
+class SettingsDialogBootstrapTest(unittest.TestCase):
+ def test_settings_dialog_opens_with_injected_catalog(self) -> None:
+ from PyQt5.QtWidgets import QWidget
+
+ from friture.settings import Settings_Dialog
+ from friture.test.test_input_device_catalog import make_catalog
+
+ ensure_qapplication()
+ parent = QWidget()
+ toolbar = MainToolbarViewModel()
+
+ dialog = Settings_Dialog(parent, toolbar, catalog=make_catalog(["Test Input"]))
+
+ self.assertEqual(dialog.comboBox_inputDevice.count(), 1)
+ self.assertTrue(dialog.checkbox_showSplash.isChecked())
+
+ def test_saved_splash_setting_is_read_by_splash_enabled(self) -> None:
+ from friture.settings import splash_enabled
+
+ isolated = IsolatedQSettings()
+ isolated.settings.beginGroup("AudioBackend")
+ isolated.settings.setValue("showSplash", False)
+ isolated.settings.endGroup()
+ isolated.settings.sync()
+
+ self.assertFalse(splash_enabled())
diff --git a/friture/test/test_audio_ingest.py b/friture/test/test_audio_ingest.py
new file mode 100644
index 00000000..89d0a9cd
--- /dev/null
+++ b/friture/test/test_audio_ingest.py
@@ -0,0 +1,106 @@
+# -*- coding: utf-8 -*-
+
+# Copyright (C) 2024 Celeste Sinéad
+
+import subprocess
+import sys
+import unittest
+
+import numpy as np
+
+from friture.audiobuffer import AudioBuffer
+from friture.test.helpers import ensure_qapplication
+
+
+class TestAudioIngestTest(unittest.TestCase):
+ def test_test_ingest_feeds_audio_buffer(self) -> None:
+ from friture.audio_ingest import TestAudioIngest
+
+ ensure_qapplication()
+ buffer = AudioBuffer()
+ ingest = TestAudioIngest(frequency_hz=440.0)
+
+ ingest.new_data_available.connect(buffer.handle_new_data)
+ ingest.fetchAudioData()
+
+ chunk = buffer.newdata()
+ self.assertEqual(chunk.shape[0], 1)
+ self.assertGreater(chunk.shape[1], 0)
+ self.assertGreater(np.max(np.abs(chunk)), 0.01)
+
+
+class IngestLevelsWidgetTest(unittest.TestCase):
+ def test_test_ingest_drives_levels_widget(self) -> None:
+ from friture.audio_ingest import TestAudioIngest
+ from friture.level_view_model import LevelViewModel
+ from friture.levels import Levels_Widget
+
+ from friture.test.helpers import attach_global_calibration, make_parent_widget
+
+ ensure_qapplication()
+ parent = make_parent_widget()
+ attach_global_calibration(parent)
+ view_model = LevelViewModel()
+ widget = Levels_Widget(parent, view_model, parent.global_calibration)
+ buffer = __import__("friture.audiobuffer", fromlist=["AudioBuffer"]).AudioBuffer()
+ widget.set_buffer(buffer)
+ ingest = TestAudioIngest(frequency_hz=440.0, amplitude=0.8)
+
+ ingest.new_data_available.connect(buffer.handle_new_data)
+ buffer.new_data_available.connect(widget.handle_new_data)
+ ingest.fetchAudioData()
+ widget.canvasUpdate()
+
+ self.assertGreater(view_model.level_data.level_rms, -20.0)
+
+
+class AudioIngestFactoryTest(unittest.TestCase):
+ def test_set_audio_ingest_replaces_singleton(self) -> None:
+ from friture.audio_ingest import (
+ TestAudioIngest,
+ get_audio_ingest,
+ reset_audio_ingest,
+ set_audio_ingest,
+ )
+
+ reset_audio_ingest()
+ try:
+ ingest = TestAudioIngest(frequency_hz=220.0)
+ set_audio_ingest(ingest)
+ self.assertIs(get_audio_ingest(), ingest)
+ finally:
+ reset_audio_ingest()
+
+ def test_importing_audiobackend_does_not_load_sounddevice(self) -> None:
+ repo_root = __import__("pathlib").Path(__file__).resolve().parents[2]
+ result = subprocess.run(
+ [
+ sys.executable,
+ "-c",
+ "import friture.audiobackend; import sys; "
+ "print('sounddevice' in sys.modules)",
+ ],
+ cwd=repo_root,
+ capture_output=True,
+ text=True,
+ check=True,
+ )
+ self.assertEqual(result.stdout.strip(), "False")
+
+
+class AudioIngestImportTest(unittest.TestCase):
+ def test_importing_audio_ingest_does_not_load_sounddevice(self) -> None:
+ repo_root = __import__("pathlib").Path(__file__).resolve().parents[2]
+ result = subprocess.run(
+ [
+ sys.executable,
+ "-c",
+ "import friture.audio_ingest; import sys; "
+ "print('sounddevice' in sys.modules)",
+ ],
+ cwd=repo_root,
+ capture_output=True,
+ text=True,
+ check=True,
+ )
+ self.assertEqual(result.stdout.strip(), "False")
diff --git a/friture/test/test_audioproc.py b/friture/test/test_audioproc.py
new file mode 100644
index 00000000..327c8dc1
--- /dev/null
+++ b/friture/test/test_audioproc.py
@@ -0,0 +1,56 @@
+# -*- coding: utf-8 -*-
+
+# Copyright (C) 2024 Celeste Sinéad
+
+# This file is part of Friture.
+#
+# Friture is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License version 3 as published by
+# the Free Software Foundation.
+#
+# Friture is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with Friture. If not, see .
+
+import unittest
+
+import numpy as np
+import numpy.testing as npt
+
+from friture.audiobackend import SAMPLING_RATE
+from friture.audioproc import audioproc
+
+
+class AudioprocTest(unittest.TestCase):
+ def test_set_fftsize_updates_window_and_frequency_bins(self) -> None:
+ proc = audioproc()
+ proc.set_fftsize(1024)
+
+ self.assertEqual(proc.fft_size, 1024)
+ self.assertEqual(len(proc.window), 1024)
+ self.assertEqual(len(proc.get_freq_scale()), 513)
+ npt.assert_array_almost_equal(proc.get_freq_scale()[0], 0.0)
+ npt.assert_array_almost_equal(proc.get_freq_scale()[-1], SAMPLING_RATE / 2)
+
+ def test_analyzelive_returns_normalized_power_spectrum(self) -> None:
+ proc = audioproc()
+ proc.set_fftsize(256)
+ samples = np.ones(256)
+
+ spectrum = proc.analyzelive(samples)
+
+ self.assertEqual(spectrum.shape, (129,))
+ self.assertGreater(spectrum[0], 0.0)
+
+ def test_norm_square_divides_by_fft_size_squared(self) -> None:
+ proc = audioproc()
+ proc.set_fftsize(4)
+ fft = np.array([4 + 0j, 0 + 0j, 0 + 0j])
+
+ spectrum = proc.norm_square(fft)
+
+ npt.assert_array_almost_equal(spectrum, [1.0, 0.0, 0.0])
diff --git a/friture/test/test_ballistic_peak.py b/friture/test/test_ballistic_peak.py
new file mode 100644
index 00000000..5594b59a
--- /dev/null
+++ b/friture/test/test_ballistic_peak.py
@@ -0,0 +1,45 @@
+# -*- coding: utf-8 -*-
+
+# Copyright (C) 2024 Celeste Sinéad
+
+# This file is part of Friture.
+#
+# Friture is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License version 3 as published by
+# the Free Software Foundation.
+#
+# Friture is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with Friture. If not, see .
+
+import os
+import unittest
+
+from PyQt5.QtWidgets import QApplication
+
+from friture.ballistic_peak import PEAK_FALLOFF, BallisticPeak
+
+os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
+_app = QApplication.instance() or QApplication([])
+
+
+class BallisticPeakTest(unittest.TestCase):
+ def test_peak_follows_rising_input(self) -> None:
+ peak = BallisticPeak()
+
+ peak.peak_iec = 0.2
+ peak.peak_iec = 0.5
+
+ self.assertAlmostEqual(peak.peak_iec, 0.5)
+
+ def test_peak_holds_before_decay(self) -> None:
+ peak = BallisticPeak()
+ peak.peak_iec = 0.8
+
+ for _ in range(PEAK_FALLOFF):
+ peak.peak_iec = 0.1
+ self.assertAlmostEqual(peak.peak_iec, 0.8)
diff --git a/friture/test/test_db_levels_dock.py b/friture/test/test_db_levels_dock.py
new file mode 100644
index 00000000..b3c7cde2
--- /dev/null
+++ b/friture/test/test_db_levels_dock.py
@@ -0,0 +1,105 @@
+# -*- coding: utf-8 -*-
+
+# Copyright (C) 2024 Celeste Sinéad
+
+import unittest
+
+from friture.db_levels_dock import DbLevelsDockWidget
+from friture.freq_weighting import WEIGHTING_A
+from friture.test.helpers import (
+ AudioHarness,
+ IsolatedQSettings,
+ attach_global_calibration,
+ make_parent_widget,
+ wire_dock_analysis_widget,
+)
+from friture.widgetdict import DB_LEVELS_WIDGET_ID, getWidgetById, widgetIds
+
+
+class WidgetDictDbLevelsTest(unittest.TestCase):
+ def test_db_levels_registered_as_id_9(self) -> None:
+ self.assertIn(DB_LEVELS_WIDGET_ID, widgetIds())
+ descriptor = getWidgetById(DB_LEVELS_WIDGET_ID)
+ self.assertEqual(descriptor["Name"], "dB levels")
+ self.assertIs(descriptor["Class"], DbLevelsDockWidget)
+
+
+class DbLevelsDockWidgetTest(unittest.TestCase):
+ def setUp(self) -> None:
+ self.parent = make_parent_widget()
+ attach_global_calibration(self.parent)
+ self.widget = DbLevelsDockWidget(self.parent)
+ self.harness = AudioHarness()
+ wire_dock_analysis_widget(self.widget, self.harness.buffer)
+ self.view_model = self.widget.view_model()
+ self.widget.set_use_global_calibration(False)
+
+ def _fresh_reading(self, offset_db: float, *, use_global: bool, local_offset: float = 0.0):
+ parent = make_parent_widget()
+ attach_global_calibration(parent)
+ parent.global_calibration.set_offset_db(offset_db)
+ widget = DbLevelsDockWidget(parent)
+ harness = AudioHarness()
+ wire_dock_analysis_widget(widget, harness.buffer)
+ widget.set_use_global_calibration(use_global)
+ if not use_global:
+ widget.set_calibration_offset(local_offset)
+ tone = harness.push_sine(440.0, 4096, amplitude=0.5)
+ widget.handle_new_data(tone)
+ return widget._meter.last_raw_rms_db, widget.view_model().level_data.level_rms
+
+ def test_global_calibration_shifts_display_when_enabled(self) -> None:
+ raw0, reading0 = self._fresh_reading(0.0, use_global=True)
+ raw10, reading10 = self._fresh_reading(10.0, use_global=True)
+
+ self.assertAlmostEqual(raw0, raw10, delta=1.5)
+ self.assertAlmostEqual(reading10 - reading0, 10.0, delta=1.5)
+
+ def test_local_override_shifts_display_by_10_db(self) -> None:
+ _, reading0 = self._fresh_reading(0.0, use_global=False, local_offset=0.0)
+ _, reading10 = self._fresh_reading(0.0, use_global=False, local_offset=10.0)
+
+ self.assertAlmostEqual(reading10 - reading0, 10.0, delta=1.5)
+
+ def test_calibrate_to_target_sets_local_offset_from_current_reading(self) -> None:
+ from friture.level_meter import raw_rms_db_from_buffer
+
+ tone = self.harness.push_sine(440.0, 4096, amplitude=0.5)
+ raw_rms_db = raw_rms_db_from_buffer(
+ self.harness.buffer,
+ weighting=self.widget._meter.weighting(),
+ )
+ self.widget.calibrate_to_target(94.0)
+ self.assertAlmostEqual(
+ self.widget.local_calibration.offset_db,
+ 94.0 - raw_rms_db,
+ )
+ for _ in range(3):
+ self.widget.handle_new_data(tone)
+ self.assertAlmostEqual(self.view_model.level_data.level_rms, 94.0, delta=3.0)
+
+ def test_unit_label_exposed_on_view_model(self) -> None:
+ self.widget.set_unit_label("dBSPL")
+ self.assertEqual(self.view_model.unit_label, "dBSPL")
+
+ def test_weighting_suffix_exposed_on_view_model(self) -> None:
+ self.widget.set_weighting(WEIGHTING_A)
+ self.assertEqual(self.view_model.weighting_suffix, " (A)")
+
+ def test_settings_round_trip(self) -> None:
+ isolated = IsolatedQSettings()
+ self.widget.set_use_global_calibration(False)
+ self.widget.set_calibration_offset(12.5)
+ self.widget.set_unit_label("dBu")
+ self.widget.set_weighting(WEIGHTING_A)
+ self.widget.saveState(isolated.settings)
+
+ other_parent = make_parent_widget()
+ attach_global_calibration(other_parent)
+ other = DbLevelsDockWidget(other_parent)
+ other.restoreState(isolated.settings)
+
+ self.assertFalse(other.use_global_calibration)
+ self.assertAlmostEqual(other.local_calibration.offset_db, 12.5)
+ self.assertEqual(other.local_calibration.unit_label, "dBu")
+ self.assertEqual(other._meter.weighting(), WEIGHTING_A)
diff --git a/friture/test/test_dock_analysis_widget.py b/friture/test/test_dock_analysis_widget.py
new file mode 100644
index 00000000..c3bc634c
--- /dev/null
+++ b/friture/test/test_dock_analysis_widget.py
@@ -0,0 +1,94 @@
+# -*- coding: utf-8 -*-
+
+# Copyright (C) 2024 Celeste Sinéad
+
+import unittest
+
+import numpy as np
+from PyQt5.QtCore import QSettings, QObject
+
+from friture.audiobuffer import AudioBuffer
+from friture.test.helpers import AudioHarness, ensure_qapplication
+
+
+class FakeDockAnalysisWidget(QObject):
+ """Minimal widget used to verify the dock analysis protocol contract."""
+
+ def __init__(self) -> None:
+ super().__init__()
+ self.audiobuffer: AudioBuffer | None = None
+ self.chunks: list[np.ndarray] = []
+ self.canvas_updates = 0
+ self.paused = False
+
+ def set_buffer(self, buffer: AudioBuffer) -> None:
+ self.audiobuffer = buffer
+
+ def handle_new_data(self, floatdata: np.ndarray) -> None:
+ self.chunks.append(floatdata.copy())
+
+ def canvasUpdate(self) -> None:
+ self.canvas_updates += 1
+
+ def pause(self) -> None:
+ self.paused = True
+
+ def restart(self) -> None:
+ self.paused = False
+
+ def settings_called(self, checked: bool) -> None:
+ del checked
+
+ def saveState(self, settings: QSettings) -> None:
+ settings.setValue("fake", True)
+
+ def restoreState(self, settings: QSettings) -> None:
+ settings.value("fake", False)
+
+ def qml_file_name(self) -> str:
+ return "Fake.qml"
+
+ def view_model(self) -> QObject:
+ return self
+
+
+class DockAnalysisWidgetProtocolTest(unittest.TestCase):
+ def test_fake_widget_satisfies_protocol(self) -> None:
+ from friture.dock_analysis_widget import DockAnalysisWidget, is_dock_analysis_widget
+
+ widget = FakeDockAnalysisWidget()
+ self.assertTrue(is_dock_analysis_widget(widget))
+ self.assertIsInstance(widget, DockAnalysisWidget)
+
+ def test_harness_wires_buffer_to_widget_like_dock(self) -> None:
+ from friture.test.helpers import wire_dock_analysis_widget
+
+ ensure_qapplication()
+ harness = AudioHarness()
+ widget = FakeDockAnalysisWidget()
+ wire_dock_analysis_widget(widget, harness.buffer)
+
+ harness.push_sine(440.0, 512)
+ widget.canvasUpdate()
+
+ self.assertEqual(len(widget.chunks), 1)
+ self.assertEqual(widget.chunks[0].shape[1], 512)
+ self.assertEqual(widget.canvas_updates, 1)
+
+
+ def test_spectrum_widget_satisfies_protocol(self) -> None:
+ from friture.dock_analysis_widget import is_dock_analysis_widget
+ from friture.spectrum import Spectrum_Widget
+
+ parent = __import__("friture.test.helpers", fromlist=["make_parent_widget"]).make_parent_widget()
+ widget = Spectrum_Widget(parent)
+ self.assertTrue(is_dock_analysis_widget(widget))
+
+
+class IngestChannelLayoutTest(unittest.TestCase):
+ def test_stereo_flag_tracks_channel_count(self) -> None:
+ from friture.dock_analysis_widget import stereo_mode_from_chunk
+
+ self.assertFalse(stereo_mode_from_chunk(np.zeros((1, 8)), False))
+ self.assertTrue(stereo_mode_from_chunk(np.zeros((2, 8)), False))
+ self.assertFalse(stereo_mode_from_chunk(np.zeros((1, 8)), True))
diff --git a/friture/test/test_freq_weighting.py b/friture/test/test_freq_weighting.py
new file mode 100644
index 00000000..726b23ed
--- /dev/null
+++ b/friture/test/test_freq_weighting.py
@@ -0,0 +1,92 @@
+# -*- coding: utf-8 -*-
+
+# Copyright (C) 2024 Celeste Sinéad
+
+import unittest
+
+import numpy as np
+
+from friture.audiobackend import SAMPLING_RATE
+from friture.freq_weighting import (
+ WEIGHTING_A,
+ WEIGHTING_C,
+ WEIGHTING_NONE,
+ WeightingFilter,
+ weighting_suffix,
+)
+from friture.level_meter import LevelMeterProcessor
+from friture.level_view_model import LevelViewModel
+from friture.level_calibration import LevelCalibration
+from friture.test.helpers import AudioHarness
+
+
+class WeightingSuffixTest(unittest.TestCase):
+ def test_none_is_empty(self) -> None:
+ self.assertEqual(weighting_suffix(WEIGHTING_NONE), "")
+
+ def test_a_suffix(self) -> None:
+ self.assertEqual(weighting_suffix(WEIGHTING_A), " (A)")
+
+
+class WeightingFilterTest(unittest.TestCase):
+ def test_a_weighting_attenuates_low_frequency_tone(self) -> None:
+ if SAMPLING_RATE != 48000:
+ self.skipTest("weighting biquads defined for 48 kHz only")
+
+ harness = AudioHarness()
+ low = harness.push_sine(100.0, 8192, amplitude=0.5)
+ mid = harness.push_sine(1000.0, 8192, amplitude=0.5)
+
+ flat = LevelMeterProcessor()
+ flat.set_weighting(WEIGHTING_NONE)
+ weighted = LevelMeterProcessor()
+ weighted.set_weighting(WEIGHTING_A)
+ view_flat = LevelViewModel()
+ view_weighted = LevelViewModel()
+ calibration = LevelCalibration()
+
+ flat.handle_new_data(low, view_flat, calibration)
+ weighted.handle_new_data(low, view_weighted, calibration)
+ low_flat = view_flat.level_data.level_rms
+ low_a = view_weighted.level_data.level_rms
+
+ flat.handle_new_data(mid, view_flat, calibration)
+ weighted.handle_new_data(mid, view_weighted, calibration)
+ mid_flat = view_flat.level_data.level_rms
+ mid_a = view_weighted.level_data.level_rms
+
+ self.assertLess(low_a, low_flat - 15.0)
+ self.assertAlmostEqual(mid_a, mid_flat, delta=2.0)
+
+ def test_c_is_closer_to_flat_at_midband_than_a(self) -> None:
+ if SAMPLING_RATE != 48000:
+ self.skipTest("weighting biquads defined for 48 kHz only")
+
+ harness = AudioHarness()
+ tone = harness.push_sine(1000.0, 8192, amplitude=0.5)
+
+ flat = LevelMeterProcessor()
+ flat.set_weighting(WEIGHTING_NONE)
+ c_meter = LevelMeterProcessor()
+ c_meter.set_weighting(WEIGHTING_C)
+ calibration = LevelCalibration()
+
+ view_flat = LevelViewModel()
+ view_c = LevelViewModel()
+
+ flat.handle_new_data(tone, view_flat, calibration)
+ c_meter.handle_new_data(tone, view_c, calibration)
+
+ flat_reading = view_flat.level_data.level_rms
+ c_delta = abs(view_c.level_data.level_rms - flat_reading)
+
+ self.assertLess(c_delta, 1.0)
+
+ def test_filter_state_resets_on_weighting_change(self) -> None:
+ filt = WeightingFilter()
+ filt.set_weighting(WEIGHTING_A)
+ samples = np.ones(64, dtype=np.float64)
+ filt.apply(samples, channel=1)
+ a_state_len = len(filt._state_ch1) # noqa: SLF001
+ filt.set_weighting(WEIGHTING_C)
+ self.assertNotEqual(len(filt._state_ch1), a_state_len) # noqa: SLF001
diff --git a/friture/test/test_global_calibration.py b/friture/test/test_global_calibration.py
new file mode 100644
index 00000000..55322f23
--- /dev/null
+++ b/friture/test/test_global_calibration.py
@@ -0,0 +1,74 @@
+# -*- coding: utf-8 -*-
+
+# Copyright (C) 2024 Celeste Sinéad
+
+import unittest
+
+import numpy as np
+
+from friture.global_calibration import GlobalCalibrationService
+from friture.level_calibration import LevelCalibration, resolve_calibration
+from friture.test.helpers import IsolatedQSettings, ensure_qapplication, make_parent_widget
+
+
+class ResolveCalibrationTest(unittest.TestCase):
+ def test_uses_global_when_enabled(self) -> None:
+ global_cal = LevelCalibration(offset_db=12.0, unit_label="dBSPL")
+ local_cal = LevelCalibration(offset_db=3.0, unit_label="dBu")
+
+ effective = resolve_calibration(global_cal, local_cal, use_global=True)
+
+ self.assertAlmostEqual(effective.offset_db, 12.0)
+ self.assertEqual(effective.unit_label, "dBSPL")
+
+ def test_uses_local_when_override_enabled(self) -> None:
+ global_cal = LevelCalibration(offset_db=12.0, unit_label="dBSPL")
+ local_cal = LevelCalibration(offset_db=3.0, unit_label="dBu")
+
+ effective = resolve_calibration(global_cal, local_cal, use_global=False)
+
+ self.assertAlmostEqual(effective.offset_db, 3.0)
+ self.assertEqual(effective.unit_label, "dBu")
+
+
+class GlobalCalibrationServiceTest(unittest.TestCase):
+ def setUp(self) -> None:
+ ensure_qapplication()
+ self.parent = make_parent_widget()
+ self.service = GlobalCalibrationService(self.parent)
+
+ def test_calibrate_to_target_sets_offset(self) -> None:
+ self.service.calibrate_to_target(-40.0, 94.0)
+
+ self.assertAlmostEqual(self.service.calibration.offset_db, 134.0)
+
+ def test_settings_round_trip(self) -> None:
+ isolated = IsolatedQSettings()
+ self.service.set_offset_db(15.5)
+ self.service.set_unit_label("dBu")
+ self.service.set_reference_note("pistonphone")
+ self.service.saveState(isolated.settings)
+
+ other_parent = make_parent_widget()
+ other = GlobalCalibrationService(other_parent)
+ other.restoreState(isolated.settings)
+ self.other_parent = other_parent
+
+ self.assertAlmostEqual(other.calibration.offset_db, 15.5)
+ self.assertEqual(other.calibration.unit_label, "dBu")
+ self.assertEqual(other.calibration.reference_note, "pistonphone")
+
+ def test_recovers_legacy_numpy_offset_in_settings(self) -> None:
+ isolated = IsolatedQSettings()
+ isolated.settings.setValue("offsetDb", np.float64(200.0))
+ isolated.settings.setValue("unitLabel", "dB FS")
+
+ other_parent = make_parent_widget()
+ other = GlobalCalibrationService(other_parent)
+ other.restoreState(isolated.settings)
+ self.other_parent = other_parent
+
+ self.assertAlmostEqual(other.calibration.offset_db, 200.0)
+ stored = isolated.settings.value("offsetDb")
+ self.assertIsInstance(stored, float)
+ self.assertAlmostEqual(stored, 200.0)
diff --git a/friture/test/test_global_frequency_calibration.py b/friture/test/test_global_frequency_calibration.py
new file mode 100644
index 00000000..7c632494
--- /dev/null
+++ b/friture/test/test_global_frequency_calibration.py
@@ -0,0 +1,80 @@
+# -*- coding: utf-8 -*-
+
+# Copyright (C) 2024 Celeste Sinéad
+
+import os
+import unittest
+
+import numpy as np
+
+from friture.global_frequency_calibration import frequency_adjustment_db
+from friture.mic_cal_file import load_mic_cal_file
+from friture.test.helpers import IsolatedQSettings, ensure_qapplication, make_parent_widget
+
+
+FIXTURES = os.path.join(os.path.dirname(__file__), "fixtures")
+
+
+class FrequencyAdjustmentTest(unittest.TestCase):
+ def test_scalar_offset_only_without_mic_cal(self) -> None:
+ adjustment = frequency_adjustment_db(np.array([440.0, 1000.0]), offset_db=10.0)
+
+ np.testing.assert_allclose(adjustment, [10.0, 10.0])
+
+ def test_mic_cal_subtracts_frequency_correction(self) -> None:
+ mic_cal = load_mic_cal_file(os.path.join(FIXTURES, "mic_cal_factory.txt"))
+ adjustment = frequency_adjustment_db(
+ np.array([1000.0, 10000.0]),
+ offset_db=0.0,
+ mic_cal=mic_cal,
+ )
+
+ self.assertAlmostEqual(adjustment[0], 0.0, places=1)
+ self.assertAlmostEqual(adjustment[1], -2.2, places=1)
+
+ def test_calibrated_spec_range_shifts_by_scalar_offset(self) -> None:
+ from friture.global_frequency_calibration import calibrated_spec_range
+ from friture.test.helpers import attach_global_calibration
+
+ ensure_qapplication()
+ parent = make_parent_widget()
+ service = attach_global_calibration(parent)
+ service.set_offset_db(114.0)
+
+ spec_min, spec_max = calibrated_spec_range(-100.0, -20.0, parent)
+
+ self.assertAlmostEqual(spec_min, 14.0)
+ self.assertAlmostEqual(spec_max, 94.0)
+
+
+class GlobalCalibrationMicCalTest(unittest.TestCase):
+ def setUp(self) -> None:
+ ensure_qapplication()
+ self.parent = make_parent_widget()
+ from friture.global_calibration import GlobalCalibrationService
+
+ self.service = GlobalCalibrationService(self.parent)
+
+ def test_load_mic_cal_file_persists_in_settings(self) -> None:
+ path = os.path.join(FIXTURES, "mic_cal_factory.txt")
+ self.service.set_mic_cal_file(path)
+
+ isolated = IsolatedQSettings()
+ self.service.saveState(isolated.settings)
+
+ other_parent = make_parent_widget()
+ from friture.global_calibration import GlobalCalibrationService
+
+ other = GlobalCalibrationService(other_parent)
+ other.restoreState(isolated.settings)
+ self.other_parent = other_parent
+
+ self.assertEqual(other.mic_cal_file_path, os.path.abspath(path))
+ self.assertIsNotNone(other.mic_cal)
+ assert other.mic_cal is not None
+ self.assertAlmostEqual(other.mic_cal.sensitivity_db, -38.6)
+ self.assertAlmostEqual(
+ other.frequency_adjustment_db(np.array([10000.0]))[0],
+ -2.2,
+ places=1,
+ )
diff --git a/friture/test/test_iec.py b/friture/test/test_iec.py
new file mode 100644
index 00000000..7837df95
--- /dev/null
+++ b/friture/test/test_iec.py
@@ -0,0 +1,70 @@
+# -*- coding: utf-8 -*-
+
+# Copyright (C) 2024 Celeste Sinéad
+
+# This file is part of Friture.
+#
+# Friture is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License version 3 as published by
+# the Free Software Foundation.
+#
+# Friture is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with Friture. If not, see .
+
+import unittest
+
+from friture.iec import (
+ dB_to_IEC,
+ iec_to_dB,
+ level_db_to_meter_fraction,
+ meter_level_for_bar,
+ normalize_unit_label,
+)
+
+
+class IECTest(unittest.TestCase):
+ def test_below_meter_range(self) -> None:
+ self.assertEqual(dB_to_IEC(-80.0), 0.0)
+
+ def test_quiet_segment(self) -> None:
+ self.assertAlmostEqual(dB_to_IEC(-65.0), 0.0125)
+
+ def test_mid_level(self) -> None:
+ self.assertAlmostEqual(dB_to_IEC(-39.0), 0.165)
+
+ def test_loud_segment(self) -> None:
+ self.assertAlmostEqual(dB_to_IEC(-10.0), 0.75)
+
+ def test_segment_boundaries_are_continuous(self) -> None:
+ boundaries = [-70.0, -60.0, -50.0, -40.0, -30.0, -20.0]
+ for dB in boundaries:
+ self.assertAlmostEqual(dB_to_IEC(dB - 1e-9), dB_to_IEC(dB))
+
+ def test_iec_to_dB_inverts_dB_to_IEC(self) -> None:
+ for dB in [-65.0, -39.0, -10.0, 0.0]:
+ self.assertAlmostEqual(iec_to_dB(dB_to_IEC(dB)), dB, places=5)
+
+ def test_positive_dbfs_is_clamped_to_full_scale(self) -> None:
+ self.assertEqual(dB_to_IEC(10.0), 1.0)
+
+ def test_spl_meter_maps_94_db_to_mid_scale(self) -> None:
+ fraction = level_db_to_meter_fraction(94.0, "dBSPL")
+ self.assertAlmostEqual(fraction, (94.0 - 40.0) / (120.0 - 40.0))
+ self.assertLess(fraction, 0.9)
+ self.assertGreater(fraction, 0.5)
+
+ def test_spl_meter_clamps_to_display_range(self) -> None:
+ self.assertEqual(level_db_to_meter_fraction(130.0, "dBSPL"), 1.0)
+ self.assertEqual(level_db_to_meter_fraction(20.0, "dBSPL"), 0.0)
+
+ def test_dbfs_unit_normalization(self) -> None:
+ self.assertEqual(normalize_unit_label("dBFS"), "dB FS")
+
+ def test_meter_level_for_bar_uses_raw_on_digital_unit(self) -> None:
+ self.assertEqual(meter_level_for_bar(94.0, -20.0, "dB FS"), -20.0)
+ self.assertEqual(meter_level_for_bar(94.0, -20.0, "dBSPL"), 94.0)
diff --git a/friture/test/test_input_device_catalog.py b/friture/test/test_input_device_catalog.py
new file mode 100644
index 00000000..deeadecc
--- /dev/null
+++ b/friture/test/test_input_device_catalog.py
@@ -0,0 +1,169 @@
+# -*- coding: utf-8 -*-
+
+# Copyright (C) 2024 Celeste Sinéad
+
+import sys
+import unittest
+from unittest.mock import MagicMock, patch
+
+from friture.test.helpers import ensure_qapplication
+
+
+def make_catalog(
+ devices: list[str] | None = None,
+ channels: list[str] | None = None,
+) -> MagicMock:
+ device_list = ["Test Input (1 ch)"] if devices is None else devices
+ channel_list = ["0"] if channels is None else channels
+ catalog = MagicMock()
+ catalog.get_readable_devices_list.return_value = device_list
+ catalog.get_readable_current_channels.return_value = channel_list
+ catalog.get_readable_current_device.return_value = 0
+ catalog.get_current_first_channel.return_value = 0
+ catalog.get_current_second_channel.return_value = 0
+ catalog.select_input_device.return_value = (True, 0)
+ catalog.select_first_channel.return_value = (True, 0)
+ catalog.select_second_channel.return_value = (True, 0)
+ return catalog
+
+
+class SettingsDialogCatalogTest(unittest.TestCase):
+ @classmethod
+ def setUpClass(cls) -> None:
+ ensure_qapplication()
+
+ def test_settings_dialog_uses_injected_catalog_without_patch(self) -> None:
+ from PyQt5.QtWidgets import QWidget
+
+ from friture.main_toolbar_view_model import MainToolbarViewModel
+ from friture.settings import Settings_Dialog
+
+ parent = QWidget()
+ dialog = Settings_Dialog(
+ parent,
+ MainToolbarViewModel(),
+ catalog=make_catalog(["Mic A", "Mic B"]),
+ )
+
+ self.assertTrue(dialog.has_input_devices())
+ self.assertEqual(dialog.comboBox_inputDevice.count(), 2)
+
+ def test_empty_catalog_does_not_call_sys_exit(self) -> None:
+ from PyQt5.QtWidgets import QWidget
+
+ from friture.main_toolbar_view_model import MainToolbarViewModel
+ from friture.settings import Settings_Dialog
+
+ parent = QWidget()
+ with patch.object(sys, "exit") as mock_exit:
+ dialog = Settings_Dialog(
+ parent,
+ MainToolbarViewModel(),
+ catalog=make_catalog([]),
+ )
+
+ mock_exit.assert_not_called()
+ self.assertFalse(dialog.has_input_devices())
+ self.assertEqual(dialog.comboBox_inputDevice.count(), 0)
+
+
+class ApplySavedInputSelectionTest(unittest.TestCase):
+ def test_selects_saved_device_and_channels(self) -> None:
+ from friture.input_device_catalog import apply_saved_input_selection
+
+ catalog = make_catalog(["Mic A", "Mic B"])
+ catalog.select_input_device.return_value = (True, 1)
+
+ self.assertTrue(
+ apply_saved_input_selection(catalog, "Mic B", 0, 1, duo_input=False)
+ )
+
+ catalog.select_input_device.assert_called_once_with(1)
+ catalog.select_first_channel.assert_called_once_with(0)
+ catalog.select_second_channel.assert_called_once_with(1)
+ catalog.set_single_input.assert_called_once_with()
+ catalog.set_duo_input.assert_not_called()
+
+ def test_duo_input_mode(self) -> None:
+ from friture.input_device_catalog import apply_saved_input_selection
+
+ catalog = make_catalog(["Mic A"])
+ catalog.select_input_device.return_value = (True, 0)
+
+ apply_saved_input_selection(catalog, "Mic A", 0, 1, duo_input=True)
+
+ catalog.set_duo_input.assert_called_once_with()
+ catalog.set_single_input.assert_not_called()
+
+ def test_unknown_device_returns_false(self) -> None:
+ from friture.input_device_catalog import apply_saved_input_selection
+
+ catalog = make_catalog(["Mic A"])
+
+ self.assertFalse(
+ apply_saved_input_selection(catalog, "Missing Mic", 0, 0, duo_input=False)
+ )
+ catalog.select_input_device.assert_not_called()
+
+
+class SettingsRestoreStateTest(unittest.TestCase):
+ @classmethod
+ def setUpClass(cls) -> None:
+ ensure_qapplication()
+
+ def test_restore_state_applies_saved_device_to_catalog(self) -> None:
+ from PyQt5.QtCore import QSettings
+ from PyQt5.QtWidgets import QWidget
+
+ from friture.main_toolbar_view_model import MainToolbarViewModel
+ from friture.settings import Settings_Dialog
+
+ catalog = make_catalog(["Mic A", "Mic B"], ["0", "1"])
+ catalog.select_input_device.return_value = (True, 1)
+ catalog.get_current_first_channel.return_value = 0
+ catalog.get_current_second_channel.return_value = 1
+
+ parent = QWidget()
+ dialog = Settings_Dialog(
+ parent,
+ MainToolbarViewModel(),
+ catalog=catalog,
+ )
+ single_input_id = dialog.inputTypeButtonGroup.id(dialog.radioButton_single)
+
+ settings = QSettings()
+ settings.setValue("deviceName", "Mic B")
+ settings.setValue("firstChannel", 0)
+ settings.setValue("secondChannel", 1)
+ settings.setValue("duoInput", single_input_id)
+
+ dialog.restoreState(settings)
+
+ catalog.select_input_device.assert_called_with(1)
+ catalog.set_single_input.assert_called()
+
+
+class RequireInputDevicesTest(unittest.TestCase):
+ @classmethod
+ def setUpClass(cls) -> None:
+ ensure_qapplication()
+
+ def test_require_input_devices_exits_when_catalog_empty(self) -> None:
+ from friture.input_device_catalog import require_input_devices
+
+ catalog = make_catalog([])
+
+ with patch.object(sys, "exit") as mock_exit, patch(
+ "friture.input_device_catalog.QtWidgets.QMessageBox.critical"
+ ), patch("friture.input_device_catalog.QtCore.QTimer.singleShot"):
+ require_input_devices(None, catalog)
+
+ mock_exit.assert_called_once_with(1)
+
+ def test_require_input_devices_noop_when_devices_present(self) -> None:
+ from friture.input_device_catalog import require_input_devices
+
+ with patch.object(sys, "exit") as mock_exit:
+ require_input_devices(None, make_catalog(["Mic"]))
+
+ mock_exit.assert_not_called()
diff --git a/friture/test/test_level_calibration.py b/friture/test/test_level_calibration.py
new file mode 100644
index 00000000..54a84bc8
--- /dev/null
+++ b/friture/test/test_level_calibration.py
@@ -0,0 +1,55 @@
+# -*- coding: utf-8 -*-
+
+# Copyright (C) 2024 Celeste Sinéad
+
+import unittest
+
+import numpy as np
+
+from friture.level_calibration import (
+ LevelCalibration,
+ apply_calibration,
+ calibration_offset_for_target,
+ read_settings_float,
+ write_settings_float,
+)
+from friture.test.helpers import IsolatedQSettings, ensure_qapplication
+
+
+class LevelCalibrationTest(unittest.TestCase):
+ def test_apply_calibration_adds_offset(self) -> None:
+ self.assertAlmostEqual(apply_calibration(-20.0, 94.0), 74.0)
+
+ def test_calibration_offset_for_target(self) -> None:
+ self.assertAlmostEqual(calibration_offset_for_target(-30.0, 94.0), 124.0)
+
+ def test_round_trip(self) -> None:
+ cal = LevelCalibration(offset_db=10.0, unit_label="dBSPL")
+ raw = -40.0
+ self.assertAlmostEqual(apply_calibration(raw, cal.offset_db), -30.0)
+
+ def test_apply_calibration_works_on_numpy_array(self) -> None:
+ raw = np.array([-40.0, -30.0, -20.0])
+ calibrated = apply_calibration(raw, 10.0)
+
+ np.testing.assert_allclose(calibrated, [-30.0, -20.0, -10.0])
+
+
+class SettingsFloatTest(unittest.TestCase):
+ def setUp(self) -> None:
+ ensure_qapplication()
+ self.settings = IsolatedQSettings().settings
+
+ def test_read_settings_float_reads_legacy_numpy_scalar(self) -> None:
+ self.settings.setValue("offsetDb", np.float64(200.0))
+
+ self.assertAlmostEqual(
+ read_settings_float(self.settings, "offsetDb", 0.0), 200.0
+ )
+
+ def test_write_settings_float_stores_plain_python_float(self) -> None:
+ write_settings_float(self.settings, "offsetDb", np.float64(15.5))
+
+ stored = self.settings.value("offsetDb")
+ self.assertIsInstance(stored, float)
+ self.assertAlmostEqual(stored, 15.5)
diff --git a/friture/test/test_level_data.py b/friture/test/test_level_data.py
new file mode 100644
index 00000000..21f7a786
--- /dev/null
+++ b/friture/test/test_level_data.py
@@ -0,0 +1,65 @@
+# -*- coding: utf-8 -*-
+
+# Copyright (C) 2024 Celeste Sinéad
+
+# This file is part of Friture.
+#
+# Friture is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License version 3 as published by
+# the Free Software Foundation.
+#
+# Friture is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with Friture. If not, see .
+
+import os
+import unittest
+
+from PyQt5.QtWidgets import QApplication
+
+from friture.iec import dB_to_IEC, level_db_to_meter_fraction
+from friture.level_data import LevelData
+from friture.level_view_model import LevelViewModel
+
+os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
+_app = QApplication.instance() or QApplication([])
+
+
+class LevelDataTest(unittest.TestCase):
+ def test_level_rms_iec_reflects_db_reading(self) -> None:
+ level = LevelData()
+ level.level_rms_raw = -30.0
+ level.level_rms = -30.0
+
+ self.assertAlmostEqual(level.level_rms_iec, dB_to_IEC(-30.0))
+
+ def test_level_max_iec_reflects_db_reading(self) -> None:
+ level = LevelData()
+ level.level_max_raw = -10.0
+ level.level_max = -10.0
+
+ self.assertAlmostEqual(level.level_max_iec, dB_to_IEC(-10.0))
+
+ def test_level_rms_iec_uses_spl_meter_range(self) -> None:
+ view_model = LevelViewModel()
+ view_model.unit_label = "dBSPL"
+ level = view_model.level_data
+ level.level_rms = 94.0
+
+ self.assertAlmostEqual(level.level_rms_iec, level_db_to_meter_fraction(94.0, "dBSPL"))
+ self.assertLess(level.level_rms_iec, 0.9)
+ self.assertGreater(level.level_rms_iec, 0.2)
+
+ def test_level_rms_iec_uses_raw_dbfs_for_digital_unit_with_offset(self) -> None:
+ view_model = LevelViewModel()
+ view_model.unit_label = "dB FS"
+ level = view_model.level_data
+ level.level_rms_raw = -20.0
+ level.level_rms = 94.0
+
+ self.assertAlmostEqual(level.level_rms_iec, dB_to_IEC(-20.0))
+ self.assertLess(level.level_rms_iec, 0.8)
diff --git a/friture/test/test_levels_widget.py b/friture/test/test_levels_widget.py
new file mode 100644
index 00000000..4df8e300
--- /dev/null
+++ b/friture/test/test_levels_widget.py
@@ -0,0 +1,95 @@
+# -*- coding: utf-8 -*-
+
+# Copyright (C) 2024 Celeste Sinéad
+
+import unittest
+
+from friture.level_view_model import LevelViewModel
+from friture.levels import Levels_Widget
+from friture.test.helpers import AudioHarness, attach_global_calibration, make_parent_widget
+
+
+class LevelsWidgetTest(unittest.TestCase):
+ def setUp(self) -> None:
+ self.parent = make_parent_widget()
+ attach_global_calibration(self.parent)
+ self.view_model = LevelViewModel()
+ self.widget = Levels_Widget(
+ self.parent, self.view_model, self.parent.global_calibration
+ )
+ self.harness = AudioHarness()
+ self.widget.set_buffer(self.harness.buffer)
+
+ def test_silence_stays_near_floor(self) -> None:
+ chunk = self.harness.push_silence(4096)
+ self.widget.handle_new_data(chunk)
+
+ self.assertLess(self.view_model.level_data.level_rms, -90.0)
+
+ def test_sine_is_louder_than_silence(self) -> None:
+ silent = self.harness.push_silence(4096)
+ self.widget.handle_new_data(silent)
+ silent_rms = self.view_model.level_data.level_rms
+
+ tone = self.harness.push_sine(440.0, 4096, amplitude=0.5)
+ self.widget.handle_new_data(tone)
+
+ self.assertGreater(self.view_model.level_data.level_rms, silent_rms + 20.0)
+ self.assertGreater(self.view_model.level_data.level_max, -20.0)
+
+ def _fresh_sidebar_reading(self, offset_db: float) -> float:
+ parent = make_parent_widget()
+ attach_global_calibration(parent)
+ parent.global_calibration.set_offset_db(offset_db)
+ view_model = LevelViewModel()
+ widget = Levels_Widget(parent, view_model, parent.global_calibration)
+ harness = AudioHarness()
+ widget.set_buffer(harness.buffer)
+ tone = harness.push_sine(440.0, 4096, amplitude=0.5)
+ widget.handle_new_data(tone)
+ return view_model.level_data.level_rms
+
+ def test_global_calibration_offset_applies_to_sidebar(self) -> None:
+ reading0 = self._fresh_sidebar_reading(0.0)
+ reading10 = self._fresh_sidebar_reading(10.0)
+
+ self.assertAlmostEqual(reading10 - reading0, 10.0, delta=1.5)
+
+ def test_sidebar_meter_stays_on_iec_scale_when_unit_is_dbfs(self) -> None:
+ parent = make_parent_widget()
+ attach_global_calibration(parent)
+ parent.global_calibration.set_offset_db(114.0)
+ parent.global_calibration.set_unit_label("dB FS")
+ view_model = LevelViewModel()
+ widget = Levels_Widget(parent, view_model, parent.global_calibration)
+ harness = AudioHarness()
+ widget.set_buffer(harness.buffer)
+ tone = harness.push_sine(440.0, 4096, amplitude=0.5)
+ widget.handle_new_data(tone)
+
+ self.assertGreater(view_model.level_data.level_rms, 50.0)
+ self.assertLess(view_model.level_data.level_rms_iec, 0.8)
+
+ def test_sidebar_meter_uses_spl_range_when_unit_is_dbspl(self) -> None:
+ parent = make_parent_widget()
+ attach_global_calibration(parent)
+ parent.global_calibration.set_offset_db(100.0)
+ parent.global_calibration.set_unit_label("dBSPL")
+ view_model = LevelViewModel()
+ widget = Levels_Widget(parent, view_model, parent.global_calibration)
+ harness = AudioHarness()
+ widget.set_buffer(harness.buffer)
+ tone = harness.push_sine(440.0, 4096, amplitude=0.5)
+ widget.handle_new_data(tone)
+
+ self.assertGreater(view_model.level_data.level_rms, 50.0)
+ self.assertLess(view_model.level_data.level_rms_iec, 0.9)
+ self.assertGreater(view_model.level_data.level_rms_iec, 0.2)
+
+ def test_stereo_input_enables_second_channel(self) -> None:
+ stereo = self.harness.push(
+ self.harness.push_sine(440.0, 1024).repeat(2, axis=0),
+ )
+ self.widget.handle_new_data(stereo)
+
+ self.assertTrue(self.view_model.two_channels)
diff --git a/friture/test/test_longlevels_calibration.py b/friture/test/test_longlevels_calibration.py
new file mode 100644
index 00000000..852b24bf
--- /dev/null
+++ b/friture/test/test_longlevels_calibration.py
@@ -0,0 +1,65 @@
+# -*- coding: utf-8 -*-
+
+# Copyright (C) 2024 Celeste Sinéad
+
+import unittest
+
+from friture.longlevels import LongLevelWidget
+from friture.level_meter import raw_rms_db_from_buffer
+from friture.test.helpers import (
+ AudioHarness,
+ IsolatedQSettings,
+ attach_global_calibration,
+ make_parent_widget,
+)
+
+
+class LongLevelsCalibrationTest(unittest.TestCase):
+ def setUp(self) -> None:
+ self.parent = make_parent_widget()
+ attach_global_calibration(self.parent)
+ self.widget = LongLevelWidget(self.parent)
+ self.widget.set_use_global_calibration(False)
+
+ def test_unit_label_updates_axis_name(self) -> None:
+ self.widget.set_unit_label("dBSPL")
+
+ self.assertEqual(
+ self.widget.view_model().vertical_axis.name,
+ "Level (dBSPL RMS)",
+ )
+
+ def test_global_unit_label_updates_axis_when_enabled(self) -> None:
+ self.widget.set_use_global_calibration(True)
+ self.parent.global_calibration.set_unit_label("dBSPL")
+
+ self.assertEqual(
+ self.widget.view_model().vertical_axis.name,
+ "Level (dBSPL RMS)",
+ )
+
+ def test_calibrate_to_target_sets_offset_from_current_buffer(self) -> None:
+ harness = AudioHarness()
+ self.widget.set_buffer(harness.buffer)
+ harness.push_sine(440.0, 4096, amplitude=0.5)
+ raw_rms_db = raw_rms_db_from_buffer(harness.buffer)
+
+ self.widget.calibrate_local_to_target(raw_rms_db, raw_rms_db + 10.0)
+
+ self.assertAlmostEqual(self.widget.local_calibration.offset_db, 10.0)
+
+ def test_settings_round_trip(self) -> None:
+ isolated = IsolatedQSettings()
+ self.widget.set_use_global_calibration(False)
+ self.widget.set_calibration_offset(8.0)
+ self.widget.set_unit_label("dBu")
+ self.widget.set_reference_note("94 dB cal")
+ self.widget.saveState(isolated.settings)
+
+ other = LongLevelWidget(self.parent)
+ other.restoreState(isolated.settings)
+
+ self.assertFalse(other.use_global_calibration)
+ self.assertAlmostEqual(other.local_calibration.offset_db, 8.0)
+ self.assertEqual(other.local_calibration.unit_label, "dBu")
+ self.assertEqual(other.local_calibration.reference_note, "94 dB cal")
diff --git a/friture/test/test_mic_cal_file.py b/friture/test/test_mic_cal_file.py
new file mode 100644
index 00000000..d43295cc
--- /dev/null
+++ b/friture/test/test_mic_cal_file.py
@@ -0,0 +1,50 @@
+# -*- coding: utf-8 -*-
+
+# Copyright (C) 2024 Celeste Sinéad
+
+import os
+import unittest
+
+import numpy as np
+
+from friture.mic_cal_file import MicCalFile, MicCalFileError, load_mic_cal_file
+
+
+FIXTURES = os.path.join(os.path.dirname(__file__), "fixtures")
+
+
+class MicCalFileParserTest(unittest.TestCase):
+ def test_loads_factory_format_with_sensitivity_header(self) -> None:
+ cal = load_mic_cal_file(os.path.join(FIXTURES, "mic_cal_factory.txt"))
+
+ self.assertAlmostEqual(cal.sensitivity_db, -38.6)
+ self.assertAlmostEqual(cal.reference_freq_hz, 1000.0)
+ self.assertGreaterEqual(len(cal.frequencies_hz), 3)
+ idx_1k = np.argmin(np.abs(cal.frequencies_hz - 1000.0))
+ self.assertAlmostEqual(cal.corrections_db[idx_1k], 0.0, delta=0.05)
+ idx_10k = np.argmin(np.abs(cal.frequencies_hz - 10000.0))
+ self.assertAlmostEqual(cal.corrections_db[idx_10k], 2.2, delta=0.05)
+
+ def test_loads_rew_format_with_dbfs_sensitivity(self) -> None:
+ cal = load_mic_cal_file(os.path.join(FIXTURES, "mic_cal_rew.cal"))
+
+ self.assertAlmostEqual(cal.sensitivity_db, -12.34)
+ self.assertAlmostEqual(cal.interpolate_db(np.array([1000.0]))[0], 0.0, delta=0.05)
+ self.assertAlmostEqual(cal.interpolate_db(np.array([10000.0]))[0], 1.5, delta=0.05)
+
+ def test_interpolate_uses_log_spacing(self) -> None:
+ cal = load_mic_cal_file(os.path.join(FIXTURES, "mic_cal_factory.txt"))
+ mid = cal.interpolate_db(np.array([632.0]))[0]
+
+ self.assertGreater(mid, 0.0)
+ self.assertLess(mid, 0.5)
+
+ def test_rejects_file_without_frequency_points(self) -> None:
+ with self.assertRaises(MicCalFileError):
+ MicCalFile.parse_text("*1000Hz -38.6\n")
+
+ def test_summary_includes_serial_from_filename(self) -> None:
+ cal = load_mic_cal_file(os.path.join(FIXTURES, "mic_cal_factory.txt"))
+
+ self.assertIn("-38.6", cal.summary())
+ self.assertIn("1000", cal.summary())
diff --git a/friture/test/test_octave_filters.py b/friture/test/test_octave_filters.py
new file mode 100644
index 00000000..2460f0ee
--- /dev/null
+++ b/friture/test/test_octave_filters.py
@@ -0,0 +1,30 @@
+# -*- coding: utf-8 -*-
+
+# Copyright (C) 2024 Celeste Sinéad
+
+import unittest
+
+import numpy as np
+
+from friture.filter import NOCTAVE
+from friture.octavefilters import Octave_Filters
+
+
+class OctaveFiltersTest(unittest.TestCase):
+ def test_twelve_bands_per_octave_has_expected_width(self) -> None:
+ filters = Octave_Filters(12)
+
+ self.assertEqual(filters.nbands, 12 * NOCTAVE)
+ self.assertEqual(len(filters.f_nominal), filters.nbands)
+
+ def test_filter_bank_returns_one_band_per_filter(self) -> None:
+ filters = Octave_Filters(12)
+ samples = np.sin(
+ 2 * np.pi * 440 * np.linspace(0, 0.25, 12000, endpoint=False),
+ )
+
+ bands, _decs = filters.filter(samples)
+
+ self.assertEqual(len(bands), filters.nbands)
+ peak = max(float(np.max(np.abs(band))) for band in bands)
+ self.assertGreater(peak, 0.0)
diff --git a/friture/test/test_octave_spectrum_widget.py b/friture/test/test_octave_spectrum_widget.py
new file mode 100644
index 00000000..d82478ac
--- /dev/null
+++ b/friture/test/test_octave_spectrum_widget.py
@@ -0,0 +1,30 @@
+# -*- coding: utf-8 -*-
+
+# Copyright (C) 2024 Celeste Sinéad
+
+import unittest
+
+import numpy as np
+
+from friture.octavespectrum import OctaveSpectrum_Widget
+from friture.test.helpers import AudioHarness, make_parent_widget
+
+
+class OctaveSpectrumWidgetTest(unittest.TestCase):
+ def setUp(self) -> None:
+ self.parent = make_parent_widget()
+ self.widget = OctaveSpectrum_Widget(self.parent)
+ self.harness = AudioHarness()
+ self.widget.set_buffer(self.harness.buffer)
+
+ def test_sine_raises_octave_band_peaks(self) -> None:
+ chunk = self.harness.push_sine(440.0, 12000, amplitude=0.5)
+ self.widget.handle_new_data(chunk)
+
+ self.assertGreater(float(np.max(self.widget.PlotZoneSpect.peak)), -40.0)
+
+ def test_silence_keeps_octave_peaks_low(self) -> None:
+ chunk = self.harness.push_silence(12000)
+ self.widget.handle_new_data(chunk)
+
+ self.assertLess(float(np.max(self.widget.PlotZoneSpect.peak)), -100.0)
diff --git a/friture/test/test_pitch_tracker.py b/friture/test/test_pitch_tracker.py
index 660ef1c1..5f748eec 100644
--- a/friture/test/test_pitch_tracker.py
+++ b/friture/test/test_pitch_tracker.py
@@ -16,56 +16,157 @@
# You should have received a copy of the GNU General Public License
# along with Friture. If not, see .
+import os
import unittest
+
import numpy as np
import numpy.testing as npt
-from friture.pitch_tracker import *
+from PyQt5.QtCore import QSettings
+from PyQt5.QtWidgets import QApplication, QWidget
+
+from friture.audiobackend import SAMPLING_RATE
+from friture.audiobuffer import AudioBuffer
+from friture.pitch_tracker import PitchTracker, PitchTrackerWidget
+from friture.pitch_tracker_settings import DEFAULT_MAX_FREQ, DEFAULT_MIN_FREQ
from friture.ringbuffer import RingBuffer
+os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
+_app = QApplication.instance() or QApplication([])
+
+
+def _push(buf: RingBuffer, chunk: np.ndarray, sample_offset: int) -> None:
+ buf.push(chunk, sample_offset / SAMPLING_RATE)
+
+
+def _sine_frame(frequency_hz: float, fft_size: int, sample_rate: int = SAMPLING_RATE) -> np.ndarray:
+ t = np.linspace(0, fft_size / sample_rate, fft_size, endpoint=False)
+ return np.array([np.sin(2 * np.pi * frequency_hz * t)])
+
+
class PitchTrackerTest(unittest.TestCase):
def test_new_frames(self) -> None:
buf = RingBuffer()
- tracker = PitchTracker(buf, fft_size=4, overlap=0.5)
- buf.push(np.array([np.arange(2)]))
- buf.push(np.array([np.arange(2, 5)]))
+ tracker = PitchTracker(buf, fft_size=4, overlap=0.5, sample_rate=SAMPLING_RATE)
+ _push(buf, np.array([np.arange(2)]), 2)
+ _push(buf, np.array([np.arange(2, 5)]), 5)
npt.assert_array_equal(
[np.array([np.arange(4)])],
- list(tracker.new_frames())
+ list(tracker.new_frames()),
)
- buf.push(np.array([np.arange(5, 8)]))
+ _push(buf, np.array([np.arange(5, 8)]), 8)
npt.assert_array_equal(
[np.array([np.arange(2, 6)]), np.array([np.arange(4, 8)])],
- list(tracker.new_frames())
+ list(tracker.new_frames()),
)
- def test_estimate_pitch(self) -> None:
+ def test_estimate_pitch_detects_sine(self) -> None:
buf = RingBuffer()
- tracker = PitchTracker(buf, fft_size=32, overlap=0.5)
- # use inverse fft to synthesize a signal where the first harmonic has
- # higher amplitude than the fundamental:
- pitch = tracker.estimate_pitch(np.array([
- np.fft.irfft(
- [0, 0, .5, 0, .7, 0, .4, 0, .2, 0, 0, 0, 0, 0, 0, 0, 0 ],
- )
- ]))
- self.assertEqual(pitch, 3000)
+ tracker = PitchTracker(
+ buf,
+ fft_size=1024,
+ overlap=0.5,
+ sample_rate=SAMPLING_RATE,
+ min_db=-80.0,
+ conf=0.3,
+ )
+ pitch = tracker.estimate_pitch(_sine_frame(440.0, 1024))
+ self.assertFalse(np.isnan(pitch))
+ self.assertAlmostEqual(pitch, 440.0, delta=5.0)
def test_update(self) -> None:
buf = RingBuffer()
- tracker = PitchTracker(buf, fft_size=32, overlap=0.5, sample_rate=32)
-
- buf.push(np.array([np.sin(np.linspace(0, 2*np.pi, 32))]))
- npt.assert_array_equal(tracker.get_estimates(1.0), np.zeros((3,)))
+ fft_size = 1024
+ tracker = PitchTracker(
+ buf,
+ fft_size=fft_size,
+ overlap=0.5,
+ sample_rate=SAMPLING_RATE,
+ min_db=-80.0,
+ conf=0.3,
+ )
+ _push(buf, _sine_frame(440.0, fft_size), fft_size)
self.assertTrue(tracker.update())
self.assertFalse(tracker.update())
- npt.assert_array_equal(tracker.get_estimates(1.0), [0, 0, 1500])
+ latest = tracker.get_latest_estimate()
+ self.assertFalse(np.isnan(latest))
+ self.assertAlmostEqual(latest, 440.0, delta=5.0)
- buf.push(np.array([np.sin(np.linspace(0, 4*np.pi, 32))]))
- self.assertTrue(tracker.update())
- self.assertFalse(tracker.update())
- # spectral leakage and low resolution mean this doesn't correctly
- # pick up the doubled pitch in the second half of the signal :/
- npt.assert_array_equal(
- tracker.get_estimates(2.0), [0, 0, 1500, 1500, 1500]
- )
+
+class PitchTrackerWidgetTest(unittest.TestCase):
+ def setUp(self) -> None:
+ self.parent = QWidget()
+ self.widget = PitchTrackerWidget(self.parent)
+ self.settings = QSettings("friture-test", "pitch-tracker-widget")
+ self.settings.clear()
+
+ def test_changing_confidence_in_settings_updates_tracker(self) -> None:
+ self.widget.settings_dialog.conf.setValue(0.33)
+ self.assertAlmostEqual(self.widget.tracker.conf, 0.33)
+
+ def test_changing_min_amplitude_in_settings_updates_tracker(self) -> None:
+ self.widget.settings_dialog.min_db.setValue(-72.0)
+ self.assertAlmostEqual(self.widget.tracker.min_db, -72.0)
+
+ def test_pitch_tracker_settings_survive_save_and_restore(self) -> None:
+ dialog = self.widget.settings_dialog
+ dialog.conf.setValue(0.25)
+ dialog.min_db.setValue(-70.0)
+ dialog.min_freq.setValue(80)
+ dialog.max_freq.setValue(900)
+ dialog.duration.setValue(15)
+
+ self.widget.saveState(self.settings)
+
+ dialog.conf.setValue(0.99)
+ dialog.min_db.setValue(-20.0)
+ dialog.min_freq.setValue(DEFAULT_MIN_FREQ)
+ dialog.max_freq.setValue(DEFAULT_MAX_FREQ)
+ dialog.duration.setValue(10)
+
+ self.widget.restoreState(self.settings)
+
+ self.assertAlmostEqual(self.widget.tracker.conf, 0.25)
+ self.assertAlmostEqual(self.widget.tracker.min_db, -70.0)
+ self.assertEqual(self.widget.min_freq, 80)
+ self.assertEqual(self.widget.max_freq, 900)
+ self.assertEqual(self.widget.duration, 15)
+
+ def test_settings_dialog_opens_from_widget(self) -> None:
+ self.widget.settings_called(True)
+ self.assertTrue(self.widget.settings_dialog.isVisible())
+
+ def test_handle_new_data_updates_pitch_display(self) -> None:
+ buffer = AudioBuffer()
+ self.widget.set_buffer(buffer)
+ self.widget.settings_dialog.conf.setValue(0.3)
+ self.widget.settings_dialog.min_db.setValue(-80.0)
+
+ fft_size = self.widget.tracker.fft_size
+ time = np.linspace(0, fft_size / SAMPLING_RATE, fft_size, endpoint=False)
+ sine = np.array([np.sin(2 * np.pi * 440.0 * time)])
+
+ buffer.handle_new_data(sine, fft_size / SAMPLING_RATE, None)
+ self.widget.handle_new_data(sine)
+
+ view_model = self.widget.view_model()
+ self.assertEqual(view_model.pitch, "440")
+ self.assertEqual(view_model.note, "A4")
+ self.assertFalse(np.isnan(view_model._pitch))
+ self.assertAlmostEqual(view_model._pitch, 440.0, delta=5.0)
+
+ def test_handle_new_data_leaves_silent_display_empty(self) -> None:
+ buffer = AudioBuffer()
+ self.widget.set_buffer(buffer)
+ self.widget.settings_dialog.conf.setValue(0.3)
+ self.widget.settings_dialog.min_db.setValue(-80.0)
+
+ fft_size = self.widget.tracker.fft_size
+ silence = np.zeros((1, fft_size))
+
+ buffer.handle_new_data(silence, fft_size / SAMPLING_RATE, None)
+ self.widget.handle_new_data(silence)
+
+ view_model = self.widget.view_model()
+ self.assertEqual(view_model.pitch, "--")
+ self.assertEqual(view_model.note, "--")
diff --git a/friture/test/test_raw_rms_measurement.py b/friture/test/test_raw_rms_measurement.py
new file mode 100644
index 00000000..ed3dec55
--- /dev/null
+++ b/friture/test/test_raw_rms_measurement.py
@@ -0,0 +1,70 @@
+# -*- coding: utf-8 -*-
+
+# Copyright (C) 2024 Celeste Sinéad
+
+import unittest
+
+from friture.level_meter import (
+ calibration_quiet_message,
+ calibration_raw_rms_db,
+ calibration_signal_too_quiet,
+ measure_raw_rms_db,
+ raw_rms_db_from_buffer,
+)
+from friture.test.helpers import AudioHarness, ensure_qapplication
+from friture.level_calibration import LevelCalibration
+from friture.level_meter import LevelMeterProcessor
+from friture.level_view_model import LevelViewModel
+
+
+class RawRmsMeasurementTest(unittest.TestCase):
+ def test_measure_raw_rms_db_from_sine(self) -> None:
+ harness = AudioHarness()
+ chunk = harness.push_sine(440.0, 4096, amplitude=0.5)
+
+ raw = measure_raw_rms_db(chunk[0])
+
+ self.assertGreater(raw, -20.0)
+ self.assertLess(raw, 0.0)
+
+ def test_raw_rms_db_from_buffer_uses_recent_audio(self) -> None:
+ harness = AudioHarness()
+ harness.push_sine(440.0, 4096, amplitude=0.5)
+
+ raw = raw_rms_db_from_buffer(harness.buffer)
+
+ self.assertGreater(raw, -20.0)
+
+ def test_empty_buffer_returns_floor(self) -> None:
+ harness = AudioHarness()
+
+ self.assertLessEqual(raw_rms_db_from_buffer(harness.buffer), -100.0)
+
+ def test_calibration_raw_rms_db_uses_live_meter_when_louder(self) -> None:
+ ensure_qapplication()
+ harness = AudioHarness()
+ chunk = harness.push_sine(1000.0, 4096, amplitude=0.5)
+ meter = LevelMeterProcessor()
+ view_model = LevelViewModel(None)
+ calibration = LevelCalibration()
+ for start in range(0, 4096, 1024):
+ meter.handle_new_data(chunk[:, start : start + 1024], view_model, calibration)
+ harness.push_silence(4096)
+
+ from_buffer = raw_rms_db_from_buffer(harness.buffer)
+ combined = calibration_raw_rms_db(harness.buffer, meter=meter)
+
+ self.assertTrue(calibration_signal_too_quiet(from_buffer))
+ self.assertFalse(calibration_signal_too_quiet(combined))
+ self.assertAlmostEqual(combined, meter.last_raw_rms_db)
+
+ def test_calibration_quiet_message_explains_offset_inflated_readings(self) -> None:
+ message = calibration_quiet_message(
+ -114.4,
+ offset_db=200.0,
+ unit_label="dBSPL",
+ )
+
+ self.assertIn("-114.4 dBFS", message)
+ self.assertIn("200.0 dB", message)
+ self.assertIn("offset", message.lower())
diff --git a/friture/test/test_ring_buffer_frame_reader.py b/friture/test/test_ring_buffer_frame_reader.py
new file mode 100644
index 00000000..322705d1
--- /dev/null
+++ b/friture/test/test_ring_buffer_frame_reader.py
@@ -0,0 +1,40 @@
+# -*- coding: utf-8 -*-
+
+# Copyright (C) 2024 Celeste Sinéad
+
+import unittest
+
+import numpy as np
+
+from friture.test.helpers import AudioHarness
+
+
+class RingBufferFrameReaderTest(unittest.TestCase):
+ def test_iter_frames_yields_fft_sized_slices_after_overlap(self) -> None:
+ from friture.ring_buffer_frame_reader import RingBufferFrameReader
+
+ harness = AudioHarness()
+ reader = RingBufferFrameReader(frame_size=1024, overlap=0.75)
+ reader.set_buffer(harness.buffer)
+
+ harness.push_sine(440.0, 4096)
+ frames = list(reader.iter_frames())
+
+ self.assertGreaterEqual(len(frames), 1)
+ self.assertEqual(frames[0].shape, (1, 1024))
+
+ def test_set_buffer_resets_read_position(self) -> None:
+ from friture.ring_buffer_frame_reader import RingBufferFrameReader
+
+ harness = AudioHarness()
+ reader = RingBufferFrameReader(frame_size=512, overlap=0.5)
+ reader.set_buffer(harness.buffer)
+ harness.push_sine(220.0, 2048)
+ list(reader.iter_frames())
+
+ reader.set_buffer(harness.buffer)
+ frames_after_reset = list(reader.iter_frames())
+ self.assertEqual(len(frames_after_reset), 0)
+
+ harness.push_sine(220.0, 2048)
+ self.assertGreaterEqual(len(list(reader.iter_frames())), 1)
diff --git a/friture/test/test_ringbuffer.py b/friture/test/test_ringbuffer.py
new file mode 100644
index 00000000..4df281fc
--- /dev/null
+++ b/friture/test/test_ringbuffer.py
@@ -0,0 +1,60 @@
+# -*- coding: utf-8 -*-
+
+# Copyright (C) 2024 Celeste Sinéad
+
+# This file is part of Friture.
+#
+# Friture is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License version 3 as published by
+# the Free Software Foundation.
+#
+# Friture is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with Friture. If not, see .
+
+import unittest
+
+import numpy as np
+import numpy.testing as npt
+
+from friture.audiobackend import SAMPLING_RATE
+from friture.ringbuffer import RingBuffer
+
+
+class RingBufferTest(unittest.TestCase):
+ def test_push_and_read_back(self) -> None:
+ buf = RingBuffer()
+ samples = np.arange(256).reshape(1, 256)
+ buf.push(samples, input_time=256 / SAMPLING_RATE)
+ npt.assert_array_equal(buf.data(256), samples)
+
+ def test_push_empty_chunk(self) -> None:
+ buf = RingBuffer()
+ samples = np.arange(64).reshape(1, 64)
+ buf.push(samples[:, :0], input_time=0.0)
+ buf.push(samples, input_time=64 / SAMPLING_RATE)
+ npt.assert_array_equal(buf.data(64), samples)
+
+ def test_data_older(self) -> None:
+ buf = RingBuffer()
+ first = np.arange(32).reshape(1, 32)
+ second = np.arange(32, 64).reshape(1, 32)
+ buf.push(first, input_time=32 / SAMPLING_RATE)
+ buf.push(second, input_time=64 / SAMPLING_RATE)
+ npt.assert_array_equal(buf.data_older(32, 32), first)
+
+ def test_data_time(self) -> None:
+ buf = RingBuffer()
+ buf.push(np.zeros((1, SAMPLING_RATE)), input_time=1.0)
+ start = buf.offset - SAMPLING_RATE
+ self.assertAlmostEqual(buf.data_time(start), 0.0)
+
+ def test_grow_on_large_push(self) -> None:
+ buf = RingBuffer()
+ samples = np.arange(50_000).reshape(1, 50_000)
+ buf.push(samples, input_time=50_000 / SAMPLING_RATE)
+ npt.assert_array_equal(buf.data(50_000), samples)
diff --git a/friture/test/test_settings.py b/friture/test/test_settings.py
new file mode 100644
index 00000000..05f9fc85
--- /dev/null
+++ b/friture/test/test_settings.py
@@ -0,0 +1,60 @@
+# -*- coding: utf-8 -*-
+
+# Copyright (C) 2024 Celeste Sinéad
+
+# This file is part of Friture.
+#
+# Friture is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License version 3 as published by
+# the Free Software Foundation.
+#
+# Friture is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with Friture. If not, see .
+
+import os
+import tempfile
+import unittest
+
+from PyQt5.QtCore import QSettings
+from PyQt5.QtWidgets import QApplication
+
+from friture.settings import splash_enabled
+
+os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
+_app = QApplication.instance() or QApplication([])
+
+
+class SplashSettingsTest(unittest.TestCase):
+ @classmethod
+ def setUpClass(cls) -> None:
+ cls._settings_dir = tempfile.mkdtemp(prefix="friture-test-settings-")
+ QSettings.setPath(QSettings.IniFormat, QSettings.UserScope, cls._settings_dir)
+
+ def setUp(self) -> None:
+ QSettings("Friture", "Friture").clear()
+
+ def test_splash_enabled_defaults_to_true(self) -> None:
+ self.assertTrue(splash_enabled())
+
+ def test_splash_disabled_when_setting_is_false(self) -> None:
+ settings = QSettings("Friture", "Friture")
+ settings.beginGroup("AudioBackend")
+ settings.setValue("showSplash", False)
+ settings.endGroup()
+ settings.sync()
+
+ self.assertFalse(splash_enabled())
+
+ def test_splash_enabled_when_setting_is_true(self) -> None:
+ settings = QSettings("Friture", "Friture")
+ settings.beginGroup("AudioBackend")
+ settings.setValue("showSplash", True)
+ settings.endGroup()
+ settings.sync()
+
+ self.assertTrue(splash_enabled())
diff --git a/friture/test/test_spectrum_frame_analyzer.py b/friture/test/test_spectrum_frame_analyzer.py
new file mode 100644
index 00000000..251c7fb1
--- /dev/null
+++ b/friture/test/test_spectrum_frame_analyzer.py
@@ -0,0 +1,38 @@
+# -*- coding: utf-8 -*-
+
+# Copyright (C) 2024 Celeste Sinéad
+
+import unittest
+
+import numpy as np
+
+from friture.audiobackend import SAMPLING_RATE
+from friture.spectrum_settings import DEFAULT_FFT_SIZE
+
+
+class SpectrumFrameAnalyzerTest(unittest.TestCase):
+ def test_sine_frame_peak_near_440_hz_without_qt(self) -> None:
+ from friture.spectrum_frame_analyzer import SpectrumFrameAnalyzer
+
+ fft_size = 2 ** DEFAULT_FFT_SIZE * 32
+ analyzer = SpectrumFrameAnalyzer(fft_size=fft_size)
+
+ time = np.linspace(0, fft_size / SAMPLING_RATE, fft_size, endpoint=False)
+ frame = np.array([0.5 * np.sin(2 * np.pi * 440.0 * time)])
+
+ result = analyzer.process_frames([frame])
+
+ self.assertIsNotNone(result)
+ assert result is not None
+ self.assertAlmostEqual(result.fmax_hz, 440.0, delta=5.0)
+
+ def test_smoothing_state_stays_inside_analyzer(self) -> None:
+ from friture.spectrum_frame_analyzer import SpectrumFrameAnalyzer
+
+ fft_size = 2048
+ analyzer = SpectrumFrameAnalyzer(fft_size=fft_size)
+ frame = np.zeros((1, fft_size))
+
+ analyzer.process_frames([frame])
+ self.assertTrue(hasattr(analyzer, "_dispbuffers1"))
+ self.assertFalse(hasattr(analyzer, "dispbuffers1"))
diff --git a/friture/test/test_spectrum_widget.py b/friture/test/test_spectrum_widget.py
new file mode 100644
index 00000000..6c05aadb
--- /dev/null
+++ b/friture/test/test_spectrum_widget.py
@@ -0,0 +1,82 @@
+# -*- coding: utf-8 -*-
+
+# Copyright (C) 2024 Celeste Sinéad
+
+import unittest
+
+from friture.spectrum import Spectrum_Widget
+from friture.test.helpers import AudioHarness, attach_global_calibration, make_parent_widget
+
+
+class SpectrumWidgetTest(unittest.TestCase):
+ def setUp(self) -> None:
+ self.parent = make_parent_widget()
+ self.widget = Spectrum_Widget(self.parent)
+ self.harness = AudioHarness()
+ self.widget.set_buffer(self.harness.buffer)
+
+ def test_sine_updates_dominant_frequency_label(self) -> None:
+ fft_size = self.widget.fft_size
+ chunk = self.harness.push_sine(440.0, fft_size)
+ self.widget.handle_new_data(chunk)
+
+ label = self.widget.view_model().fmaxValue
+ self.assertIn("Hz", label)
+ self.assertAlmostEqual(float(label.replace(" Hz", "")), 440.0, delta=5.0)
+
+ def test_silence_does_not_crash_spectrum_update(self) -> None:
+ chunk = self.harness.push_silence(self.widget.fft_size)
+ self.widget.handle_new_data(chunk)
+
+ self.assertIsNotNone(self.widget.view_model().fmaxValue)
+
+ def test_mic_cal_file_shifts_spectrum_at_high_frequency(self) -> None:
+ import os
+
+ parent = make_parent_widget()
+ attach_global_calibration(parent)
+ cal_path = os.path.join(
+ os.path.dirname(__file__), "fixtures", "mic_cal_factory.txt"
+ )
+ parent.global_calibration.set_mic_cal_file(cal_path)
+
+ baseline_widget = Spectrum_Widget(parent)
+ baseline_harness = AudioHarness()
+ baseline_widget.set_buffer(baseline_harness.buffer)
+ parent.global_calibration.clear_mic_cal_file()
+
+ fft_size = baseline_widget.fft_size
+ chunk = baseline_harness.push_sine(10000.0, fft_size, amplitude=0.5)
+ baseline_widget.handle_new_data(chunk)
+ baseline_peak = float(baseline_widget.PlotZoneSpect.peak.max())
+
+ parent.global_calibration.set_mic_cal_file(cal_path)
+ calibrated_widget = Spectrum_Widget(parent)
+ calibrated_harness = AudioHarness()
+ calibrated_widget.set_buffer(calibrated_harness.buffer)
+ chunk = calibrated_harness.push_sine(10000.0, fft_size, amplitude=0.5)
+ calibrated_widget.handle_new_data(chunk)
+ calibrated_peak = float(calibrated_widget.PlotZoneSpect.peak.max())
+
+ self.assertAlmostEqual(calibrated_peak - baseline_peak, -2.2, delta=1.0)
+
+ def test_scalar_offset_shifts_axis_not_trace_saturation(self) -> None:
+ parent = make_parent_widget()
+ attach_global_calibration(parent)
+ parent.global_calibration.set_offset_db(114.0)
+
+ widget = Spectrum_Widget(parent)
+ harness = AudioHarness()
+ widget.set_buffer(harness.buffer)
+ widget.setmin(-100)
+ widget.setmax(-20)
+
+ chunk = harness.push_sine(440.0, widget.fft_size, amplitude=0.5)
+ widget.handle_new_data(chunk)
+
+ self.assertAlmostEqual(widget.spec_min, 14.0, delta=1.0)
+ self.assertAlmostEqual(widget.spec_max, 94.0, delta=1.0)
+ peak = float(widget.PlotZoneSpect.peak.max())
+ self.assertGreater(peak, widget.spec_min + 5.0)
+ self.assertLess(peak, widget.spec_max - 5.0)
+
diff --git a/friture/ui_settings.py b/friture/ui_settings.py
index 73d17032..adfa1c51 100644
--- a/friture/ui_settings.py
+++ b/friture/ui_settings.py
@@ -2,7 +2,7 @@
# Form implementation generated from reading ui file 'ui/settings.ui'
#
-# Created by: PyQt5 UI code generator 5.15.10
+# Created by: PyQt5 UI code generator 5.15.11
#
# WARNING: Any manual changes made to this file will be lost when pyuic5 is
# run again. Do not edit this file unless you know what you are doing.
@@ -97,8 +97,25 @@ def setupUi(self, Settings_Dialog):
self.spinBox_historyLength.setObjectName("spinBox_historyLength")
self.formLayout_2.setWidget(1, QtWidgets.QFormLayout.FieldRole, self.spinBox_historyLength)
self.verticalLayout_5.addWidget(self.playbackGroup)
+ self.startupGroup = QtWidgets.QGroupBox(Settings_Dialog)
+ self.startupGroup.setObjectName("startupGroup")
+ self.formLayout_startup = QtWidgets.QFormLayout(self.startupGroup)
+ self.formLayout_startup.setObjectName("formLayout_startup")
+ self.label_showSplash = QtWidgets.QLabel(self.startupGroup)
+ self.label_showSplash.setObjectName("label_showSplash")
+ self.formLayout_startup.setWidget(0, QtWidgets.QFormLayout.LabelRole, self.label_showSplash)
+ self.checkbox_showSplash = QtWidgets.QCheckBox(self.startupGroup)
+ self.checkbox_showSplash.setText("")
+ self.checkbox_showSplash.setChecked(True)
+ self.checkbox_showSplash.setObjectName("checkbox_showSplash")
+ self.formLayout_startup.setWidget(0, QtWidgets.QFormLayout.FieldRole, self.checkbox_showSplash)
+ self.verticalLayout_5.addWidget(self.startupGroup)
spacerItem2 = QtWidgets.QSpacerItem(20, 40, QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Expanding)
self.verticalLayout_5.addItem(spacerItem2)
+ self.buttonBox = QtWidgets.QDialogButtonBox(Settings_Dialog)
+ self.buttonBox.setStandardButtons(QtWidgets.QDialogButtonBox.Close)
+ self.buttonBox.setObjectName("buttonBox")
+ self.verticalLayout_5.addWidget(self.buttonBox)
self.retranslateUi(Settings_Dialog)
QtCore.QMetaObject.connectSlotsByName(Settings_Dialog)
@@ -117,4 +134,6 @@ def retranslateUi(self, Settings_Dialog):
self.label_showPlayback.setText(_translate("Settings_Dialog", "Show Playback Controls"))
self.label_historyLength.setText(_translate("Settings_Dialog", "History Length"))
self.spinBox_historyLength.setSuffix(_translate("Settings_Dialog", " s"))
+ self.startupGroup.setTitle(_translate("Settings_Dialog", "Startup"))
+ self.label_showSplash.setText(_translate("Settings_Dialog", "Show splash screen on launch"))
from . import friture_rc
diff --git a/friture/widgetdict.py b/friture/widgetdict.py
index 89084ae7..5e04216d 100644
--- a/friture/widgetdict.py
+++ b/friture/widgetdict.py
@@ -25,6 +25,9 @@
from friture.delay_estimator import Delay_Estimator_Widget
from friture.longlevels import LongLevelWidget
from friture.pitch_tracker import PitchTrackerWidget
+from friture.db_levels_dock import DbLevelsDockWidget
+
+DB_LEVELS_WIDGET_ID = 9
widgets = [
{'Id': 1, "Class": Scope_Widget, "Name": "Scope"},
@@ -35,6 +38,7 @@
{'Id': 6, "Class": Delay_Estimator_Widget, "Name": "Delay Estimator"},
{'Id': 7, "Class": LongLevelWidget, "Name": "Long-time levels"},
{'Id': 8, "Class": PitchTrackerWidget, "Name": "Pitch Tracker"},
+ {'Id': DB_LEVELS_WIDGET_ID, "Class": DbLevelsDockWidget, "Name": "dB levels"},
]
diff --git a/pyproject.toml b/pyproject.toml
index 0a1af8dd..9a9fedfe 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -46,6 +46,7 @@ dependencies = [
dev = [
"mypy==1.18.2",
"PyQt5-stubs==5.15.6.0",
+ "coverage==7.10.7",
]
[project.scripts]
diff --git a/ui/settings.ui b/ui/settings.ui
index a5be73d1..a35f512f 100644
--- a/ui/settings.ui
+++ b/ui/settings.ui
@@ -184,6 +184,32 @@
+ -
+
+
+ Startup
+
+
+
-
+
+
+ Show splash screen on launch
+
+
+
+ -
+
+
+
+
+
+ true
+
+
+
+
+
+
-
@@ -197,6 +223,13 @@
+ -
+
+
+ QDialogButtonBox::Close
+
+
+