diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 81232b2b..2b48a5c6 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,9 +141,11 @@ 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] + # macOS build runs in parallel but does not gate release until a macOS runner is available. + needs: [build-windows, build-linux] steps: - name: Checkout uses: actions/checkout@v5 @@ -137,7 +165,8 @@ jobs: fail_on_unmatched_files: true files: | **/friture*.msi - **/friture*.dmg **/friture*.AppImage + **/friture.zip + **/friture*.appx env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 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/HistPlot.qml b/friture/HistPlot.qml index 7a095831..5f0921d2 100644 --- a/friture/HistPlot.qml +++ b/friture/HistPlot.qml @@ -10,6 +10,13 @@ Plot { scopedata: viewModel + PlotCurve { + visible: scopedata.reference_overlay_visible + anchors.fill: parent + color: "#888888" + curve: scopedata.reference_overlay + } + Repeater { model: scopedata.plot_items 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/Spectrum.qml b/friture/Spectrum.qml index 347b1838..be3a8a37 100644 --- a/friture/Spectrum.qml +++ b/friture/Spectrum.qml @@ -10,6 +10,13 @@ Plot { scopedata: viewModel + PlotCurve { + visible: scopedata.reference_overlay_visible + anchors.fill: parent + color: "#888888" + curve: scopedata.reference_overlay + } + Repeater { model: scopedata.plot_items diff --git a/friture/__init__.py b/friture/__init__.py index 16d100fd..705053dd 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.57" +__releasedate__ = "2026-06-13" __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/histplot.py b/friture/histplot.py index 6ac97f28..0ddbad8c 100644 --- a/friture/histplot.py +++ b/friture/histplot.py @@ -24,6 +24,7 @@ from friture.filled_curve import CurveType, FilledCurve from friture.histplot_data import HistPlot_Data from friture.plotting.coordinateTransform import CoordinateTransform +from friture.reference_overlay_plot import ReferenceOverlayPlot import friture.plotting.frequency_scales as fscales from friture.pitch_tracker import format_frequency from friture.store import GetStore @@ -68,6 +69,13 @@ def __init__(self, parent): self.normHorizontalScaleTransform.setScale(fscales.Logarithmic) self._histplot_data.horizontal_axis.setScale(fscales.Logarithmic) + self._reference_overlay = ReferenceOverlayPlot( + self._histplot_data, + self.normHorizontalScaleTransform, + self.normVerticalScaleTransform, + display_mode="octave", + ) + def qml_file_name(self): return "HistPlot.qml" @@ -75,6 +83,8 @@ def view_model(self): return self._histplot_data def setdata(self, fl, fh, fc, y): + center_hz = numpy.sqrt(numpy.asarray(fl, dtype=float) * numpy.asarray(fh, dtype=float)) + self._reference_overlay.set_frequencies_hz(center_hz) if not self.paused: M = numpy.max(y) m = self.normVerticalScaleTransform.coord_min @@ -135,6 +145,13 @@ def setspecrange(self, spec_min, spec_max): self._histplot_data.vertical_axis.setRange(spec_min, spec_max) self.normVerticalScaleTransform.setRange(spec_min, spec_max) + self._reference_overlay.refresh() + + def set_reference_preset(self, preset: int) -> None: + self._reference_overlay.set_preset(preset) + + def set_reference_offset_db(self, offset_db: float) -> None: + self._reference_overlay.set_offset_db(offset_db) def setweighting(self, weighting): if weighting == 0: 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..99371a17 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 @@ -162,6 +166,12 @@ def setbandsperoctave(self, bandsperoctave): # reset kernel and parameters for the smoothing filter self.setresponsetime(self.response_time) + def set_reference_preset(self, preset: int) -> None: + self.PlotZoneSpect.set_reference_preset(preset) + + def set_reference_offset_db(self, offset_db: float) -> None: + self.PlotZoneSpect.set_reference_offset_db(offset_db) + def settings_called(self, checked): self.settings_dialog.show() diff --git a/friture/octavespectrum_settings.py b/friture/octavespectrum_settings.py index 19766a89..46b6c97e 100644 --- a/friture/octavespectrum_settings.py +++ b/friture/octavespectrum_settings.py @@ -20,6 +20,8 @@ import logging from PyQt5 import QtWidgets +from friture.settings_dialog_layout import create_form_layout +from friture.reference_settings_rows import ReferenceOverlaySettingsRows # shared with octavespectrum.py DEFAULT_SPEC_MIN = -80 @@ -42,7 +44,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") @@ -86,19 +88,25 @@ def __init__(self, parent, view_model): self.comboBox_response_time.addItem("5s (Very Slow)") self.comboBox_response_time.setCurrentIndex(DEFAULT_RESPONSE_TIME_INDEX) + self._reference_rows = ReferenceOverlaySettingsRows(self.formLayout) + self.formLayout.addRow("Bands per octave:", self.comboBox_bandsperoctave) self.formLayout.addRow("Min:", self.spinBox_specmin) self.formLayout.addRow("Max:", self.spinBox_specmax) 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) self.comboBox_weighting.currentIndexChanged.connect(view_model.setweighting) self.comboBox_response_time.currentIndexChanged.connect(self.responsetimechanged) + self._reference_rows.comboBox_reference.currentIndexChanged.connect( + self.view_model.set_reference_preset + ) + self._reference_rows.doubleSpinBox_reference_offset.valueChanged.connect( + self.view_model.set_reference_offset_db + ) # slot def bandsperoctavechanged(self, index): @@ -128,6 +136,7 @@ def saveState(self, settings): settings.setValue("Max", self.spinBox_specmax.value()) settings.setValue("weighting", self.comboBox_weighting.currentIndex()) settings.setValue("response_time", self.comboBox_response_time.currentIndex()) + self._reference_rows.save_state(settings) # method def restoreState(self, settings): @@ -141,3 +150,4 @@ def restoreState(self, settings): self.comboBox_weighting.setCurrentIndex(weighting) response_time_index = settings.value("response_time", DEFAULT_RESPONSE_TIME_INDEX, type=int) self.comboBox_response_time.setCurrentIndex(response_time_index) + self._reference_rows.restore_state(settings) 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/reference_curves.py b/friture/reference_curves.py new file mode 100644 index 00000000..11b1f8c3 --- /dev/null +++ b/friture/reference_curves.py @@ -0,0 +1,99 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +# Copyright (C) 2024 Celeste Sinéad + +"""Reference target curves for FFT and octave spectrum overlays.""" + +from __future__ import annotations + +import numpy as np + +REFERENCE_NONE = 0 +REFERENCE_FLAT = 1 +REFERENCE_PINK = 2 +REFERENCE_A_WEIGHT = 3 +REFERENCE_HOUSE = 4 + +REFERENCE_PRESET_NAMES = ("None", "Flat", "Pink", "A-weighting", "House") +DEFAULT_REFERENCE_PRESET = REFERENCE_NONE +DEFAULT_REFERENCE_OFFSET_DB = 0.0 +DEFAULT_ANCHOR_FREQ_HZ = 1000.0 +HOUSE_ROLLOFF_START_HZ = 2000.0 + + +def a_weighting_db(frequencies_hz: np.ndarray) -> np.ndarray: + """Same A-weighting curve as ``audioproc.update_freq_cache`` (dB).""" + f = np.asarray(frequencies_hz, dtype=float) + f2 = f * f + ra = ( + 12200.0**2 + * f2 + * f2 + / ( + (f2 + 20.6**2) + * (f2 + 12200.0**2) + * np.sqrt(f2 + 107.7**2) + * np.sqrt(f2 + 737.9**2) + ) + ) + return 2.0 + 20.0 * np.log10(ra + 1e-50) + + +def _pink_fft_db( + frequencies_hz: np.ndarray, anchor_freq_hz: float +) -> np.ndarray: + freqs = np.maximum(np.asarray(frequencies_hz, dtype=float), 1e-20) + anchor = max(anchor_freq_hz, 1e-20) + return 10.0 * np.log10(freqs / anchor) + + +def _house_db(frequencies_hz: np.ndarray) -> np.ndarray: + freqs = np.asarray(frequencies_hz, dtype=float) + values = np.zeros_like(freqs) + above = freqs > HOUSE_ROLLOFF_START_HZ + values[above] = -10.0 * np.log10(freqs[above] / HOUSE_ROLLOFF_START_HZ) + return values + + +def reference_curve_db( + preset: int, + frequencies_hz: np.ndarray, + *, + offset_db: float = DEFAULT_REFERENCE_OFFSET_DB, + anchor_freq_hz: float = DEFAULT_ANCHOR_FREQ_HZ, + display_mode: str = "fft", +) -> np.ndarray | None: + """Return dB target values aligned with the plot Y axis, or None if hidden.""" + if preset == REFERENCE_NONE: + return None + + freqs = np.asarray(frequencies_hz, dtype=float) + if freqs.size == 0: + return np.array([], dtype=float) + + if preset == REFERENCE_FLAT: + values = np.zeros_like(freqs) + elif preset == REFERENCE_PINK: + if display_mode == "octave": + values = np.zeros_like(freqs) + else: + values = _pink_fft_db(freqs, anchor_freq_hz) + elif preset == REFERENCE_A_WEIGHT: + values = a_weighting_db(freqs) + values -= float(a_weighting_db(np.array([anchor_freq_hz]))[0]) + elif preset == REFERENCE_HOUSE: + values = _house_db(freqs) + else: + return None + + return values + float(offset_db) + + +def reference_overlay_label(preset: int, offset_db: float) -> str: + if preset == REFERENCE_NONE: + return "" + name = REFERENCE_PRESET_NAMES[preset] + if abs(offset_db) < 0.05: + return f"Target: {name}" + return f"Target: {name} {offset_db:+.1f} dB" diff --git a/friture/reference_overlay_plot.py b/friture/reference_overlay_plot.py new file mode 100644 index 00000000..0ad215ec --- /dev/null +++ b/friture/reference_overlay_plot.py @@ -0,0 +1,68 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +# Copyright (C) 2024 Celeste Sinéad + +"""Draw and update optional reference target overlays on frequency plots.""" + +from __future__ import annotations + +import numpy as np + +from friture.reference_curves import ( + DEFAULT_REFERENCE_OFFSET_DB, + DEFAULT_REFERENCE_PRESET, + reference_curve_db, + reference_overlay_label, +) + + +class ReferenceOverlayPlot: + def __init__( + self, + plot_data, + norm_horizontal, + norm_vertical, + *, + display_mode: str, + ) -> None: + self._plot_data = plot_data + self._norm_horizontal = norm_horizontal + self._norm_vertical = norm_vertical + self._display_mode = display_mode + self._preset = DEFAULT_REFERENCE_PRESET + self._offset_db = DEFAULT_REFERENCE_OFFSET_DB + self._frequencies_hz = np.array([], dtype=float) + self._curve = plot_data.reference_overlay + + def set_preset(self, preset: int) -> None: + if self._preset != preset: + self._preset = preset + self.refresh() + + def set_offset_db(self, offset_db: float) -> None: + offset_db = float(offset_db) + if self._offset_db != offset_db: + self._offset_db = offset_db + self.refresh() + + def set_frequencies_hz(self, frequencies_hz: np.ndarray) -> None: + self._frequencies_hz = np.asarray(frequencies_hz, dtype=float) + self.refresh() + + def refresh(self) -> None: + values = reference_curve_db( + self._preset, + self._frequencies_hz, + offset_db=self._offset_db, + display_mode=self._display_mode, + ) + if values is None or self._frequencies_hz.size == 0: + self._plot_data.set_reference_overlay_visible(False) + return + + screen_x = self._norm_horizontal.toScreen(self._frequencies_hz) + screen_y = 1.0 - self._norm_vertical.toScreen(values) + self._curve.name = reference_overlay_label(self._preset, self._offset_db) + self._curve.setData(screen_x, screen_y) + self._plot_data.set_reference_overlay_visible(True) diff --git a/friture/reference_settings_rows.py b/friture/reference_settings_rows.py new file mode 100644 index 00000000..62f51dc3 --- /dev/null +++ b/friture/reference_settings_rows.py @@ -0,0 +1,56 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +# Copyright (C) 2024 Celeste Sinéad + +"""Shared reference overlay controls for spectrum settings dialogs.""" + +from __future__ import annotations + +from PyQt5 import QtWidgets + +from friture.reference_curves import ( + DEFAULT_REFERENCE_OFFSET_DB, + DEFAULT_REFERENCE_PRESET, + REFERENCE_PRESET_NAMES, +) + + +class ReferenceOverlaySettingsRows: + def __init__(self, form_layout: QtWidgets.QFormLayout) -> None: + self.comboBox_reference = QtWidgets.QComboBox() + for name in REFERENCE_PRESET_NAMES: + self.comboBox_reference.addItem(name) + self.comboBox_reference.setCurrentIndex(DEFAULT_REFERENCE_PRESET) + + self.doubleSpinBox_reference_offset = QtWidgets.QDoubleSpinBox() + self.doubleSpinBox_reference_offset.setDecimals(1) + self.doubleSpinBox_reference_offset.setRange(-200.0, 200.0) + self.doubleSpinBox_reference_offset.setSuffix(" dB") + self.doubleSpinBox_reference_offset.setValue(DEFAULT_REFERENCE_OFFSET_DB) + + form_layout.addRow("Reference overlay:", self.comboBox_reference) + form_layout.addRow("Overlay offset:", self.doubleSpinBox_reference_offset) + + def preset(self) -> int: + return self.comboBox_reference.currentIndex() + + def offset_db(self) -> float: + return float(self.doubleSpinBox_reference_offset.value()) + + def load(self, preset: int, offset_db: float) -> None: + self.comboBox_reference.setCurrentIndex(preset) + self.doubleSpinBox_reference_offset.setValue(offset_db) + + def save_state(self, settings) -> None: + settings.setValue("referencePreset", self.preset()) + settings.setValue("referenceOffsetDb", self.offset_db()) + + def restore_state(self, settings) -> None: + preset = settings.value( + "referencePreset", DEFAULT_REFERENCE_PRESET, type=int + ) + offset_db = settings.value( + "referenceOffsetDb", DEFAULT_REFERENCE_OFFSET_DB, type=float + ) + self.load(preset, offset_db) 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/scope_data.py b/friture/scope_data.py index 5fe3b571..f0b9c40d 100644 --- a/friture/scope_data.py +++ b/friture/scope_data.py @@ -28,11 +28,15 @@ class Scope_Data(QtCore.QObject): show_color_axis_changed = QtCore.pyqtSignal(bool) show_legend_changed = QtCore.pyqtSignal(bool) plot_items_changed = QtCore.pyqtSignal() + reference_overlay_changed = QtCore.pyqtSignal() + reference_overlay_visible_changed = QtCore.pyqtSignal(bool) def __init__(self, parent=None): super().__init__(parent) self._plot_items = [] + self._reference_overlay = Curve(self) + self._reference_overlay_visible = False self._horizontal_axis = Axis(self) self._vertical_axis = Axis(self) self._color_axis = Axis(self) @@ -87,3 +91,21 @@ def show_legend(self, show_legend): if self._show_legend != show_legend: self._show_legend = show_legend self.show_legend_changed.emit(show_legend) + + @pyqtProperty(Curve, notify=reference_overlay_changed) # type: ignore + def reference_overlay(self): + return self._reference_overlay + + def set_reference_overlay(self, curve: Curve) -> None: + if self._reference_overlay is not curve: + self._reference_overlay = curve + self.reference_overlay_changed.emit() + + @pyqtProperty(bool, notify=reference_overlay_visible_changed) # type: ignore + def reference_overlay_visible(self): + return self._reference_overlay_visible + + def set_reference_overlay_visible(self, visible: bool) -> None: + if self._reference_overlay_visible != visible: + self._reference_overlay_visible = visible + self.reference_overlay_visible_changed.emit(visible) 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..40aa1673 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 + @property + def proc(self): + return self._analyzer.proc - if available < 0: - # ringbuffer must have grown or something... - available = 0 - self.old_index = index + @property + def fft_size(self): + return self._analyzer.fft_size - # 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 freq(self): + return self._analyzer.freq - if realizable > 0: - sp1n = zeros((len(self.freq), realizable), dtype=float64) - sp2n = zeros((len(self.freq), realizable), dtype=float64) + @property + def dual_channels(self): + return self._analyzer.dual_channels - for i in range(realizable): - floatdata = self.audiobuffer.data_indexed(self.old_index, self.fft_size) + @dual_channels.setter + def dual_channels(self, value): + self._analyzer.set_dual_channels(value) - # first channel - # FFT transform - sp1n[:, i] = self.proc.analyzelive(floatdata[0, :]) + @property + def weighting(self): + return self._analyzer.weighting - if self.dual_channels and floatdata.shape[0] > 1: - # second channel for comparison - sp2n[:, i] = self.proc.analyzelive(floatdata[1, :]) + @weighting.setter + def weighting(self, value): + self._analyzer.set_weighting(value) - self.old_index += int(needed) + @property + def response_time(self): + return self._analyzer.response_time - # 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 + @response_time.setter + def response_time(self, value): + self._analyzer.set_response_time(value) - sp1.shape = self.freq.shape - sp2.shape = self.freq.shape - self.w.shape = self.freq.shape - - 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 - - # 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 @@ -298,6 +191,12 @@ def setShowFreqLabel(self, showFreqLabel): def setShowPitchLabel(self, showPitchLabel): self.PlotZoneSpect.setShowPitchLabel(showPitchLabel) + def set_reference_preset(self, preset: int) -> None: + self.PlotZoneSpect.set_reference_preset(preset) + + def set_reference_offset_db(self, offset_db: float) -> None: + self.PlotZoneSpect.set_reference_offset_db(offset_db) + def settings_called(self, checked): self.settings_dialog.show() diff --git a/friture/spectrumPlotWidget.py b/friture/spectrumPlotWidget.py index f0085c04..f64ddc3f 100644 --- a/friture/spectrumPlotWidget.py +++ b/friture/spectrumPlotWidget.py @@ -14,6 +14,7 @@ from friture.spectrum_data import Spectrum_Data from friture.filled_curve import CurveType, FilledCurve from friture.pitch_tracker import format_frequency +from friture.reference_overlay_plot import ReferenceOverlayPlot from friture.store import GetStore # The peak decay rates (magic goes here :). @@ -63,6 +64,13 @@ def __init__(self, parent): self.normVerticalScaleTransform = CoordinateTransform(0, 1, 1, 0, 0) self.normHorizontalScaleTransform = CoordinateTransform(0, 22000, 1, 0, 0) + self._reference_overlay = ReferenceOverlayPlot( + self._spectrum_data, + self.normHorizontalScaleTransform, + self.normVerticalScaleTransform, + display_mode="fft", + ) + def qml_file_name(self): return "Spectrum.qml" @@ -72,6 +80,7 @@ def view_model(self): def setfreqscale(self, scale): self.normHorizontalScaleTransform.setScale(scale) self._spectrum_data.horizontal_axis.setScale(scale) + self._reference_overlay.refresh() def setfreqrange(self, minfreq, maxfreq): self.xmin = minfreq @@ -79,6 +88,7 @@ def setfreqrange(self, minfreq, maxfreq): self._spectrum_data.horizontal_axis.setRange(minfreq, maxfreq) self.normHorizontalScaleTransform.setRange(minfreq, maxfreq) + self._reference_overlay.refresh() def setspecrange(self, spec_min, spec_max): if spec_min > spec_max: @@ -86,6 +96,13 @@ def setspecrange(self, spec_min, spec_max): self._spectrum_data.vertical_axis.setRange(spec_min, spec_max) self.normVerticalScaleTransform.setRange(spec_min, spec_max) + self._reference_overlay.refresh() + + def set_reference_preset(self, preset: int) -> None: + self._reference_overlay.set_preset(preset) + + def set_reference_offset_db(self, offset_db: float) -> None: + self._reference_overlay.set_offset_db(offset_db) def setweighting(self, weighting): if weighting == 0: @@ -120,6 +137,7 @@ def set_baseline_dataUnits(self, baseline): self._baseline = Baseline.DATA_ZERO def setdata(self, x, y, fmax, fpitch): + self._reference_overlay.set_frequencies_hz(x) if not self.paused: if fmax < 2e2: text = "%.1f Hz" % (fmax) 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..7f71e186 100644 --- a/friture/spectrum_settings.py +++ b/friture/spectrum_settings.py @@ -21,6 +21,8 @@ from PyQt5 import QtWidgets from friture.audiobackend import SAMPLING_RATE +from friture.settings_dialog_layout import create_form_layout +from friture.reference_settings_rows import ReferenceOverlaySettingsRows import friture.plotting.frequency_scales as fscales # shared with spectrum_settings.py @@ -48,7 +50,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") @@ -133,6 +135,8 @@ def __init__(self, parent, view_model): self.checkBox_showPitchLabel.setObjectName("showPitchLabels") self.checkBox_showPitchLabel.setChecked(DEFAULT_SHOW_PITCH_LABELS) + self._reference_rows = ReferenceOverlaySettingsRows(self.formLayout) + self.formLayout.addRow("Measurement type:", self.comboBox_dual_channel) self.formLayout.addRow("FFT Size:", self.comboBox_fftsize) self.formLayout.addRow("Frequency scale:", self.comboBox_freqscale) @@ -145,8 +149,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) @@ -158,6 +160,12 @@ def __init__(self, parent, view_model): self.comboBox_response_time.currentIndexChanged.connect(self.responsetimechanged) self.checkBox_showFreqLabels.toggled.connect(self.view_model.setShowFreqLabel) self.checkBox_showPitchLabel.toggled.connect(self.view_model.setShowPitchLabel) + self._reference_rows.comboBox_reference.currentIndexChanged.connect( + self.view_model.set_reference_preset + ) + self._reference_rows.doubleSpinBox_reference_offset.valueChanged.connect( + self.view_model.set_reference_offset_db + ) # slot def dualchannelchanged(self, index): @@ -205,6 +213,7 @@ def saveState(self, settings): settings.setValue("responseTime", self.comboBox_response_time.currentIndex()) settings.setValue("showFreqLabels", self.checkBox_showFreqLabels.isChecked()) settings.setValue("showPitchLabel", self.checkBox_showPitchLabel.isChecked()) + self._reference_rows.save_state(settings) # method def restoreState(self, settings): @@ -228,3 +237,4 @@ def restoreState(self, settings): self.checkBox_showFreqLabels.setChecked(showFreqLabels) showPitchLabel = settings.value("showPitchLabel", DEFAULT_SHOW_PITCH_LABELS, type=bool) self.checkBox_showPitchLabel.setChecked(showPitchLabel) + self._reference_rows.restore_state(settings) 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_reference_curves.py b/friture/test/test_reference_curves.py new file mode 100644 index 00000000..c3044659 --- /dev/null +++ b/friture/test/test_reference_curves.py @@ -0,0 +1,101 @@ +# -*- coding: utf-8 -*- + +# Copyright (C) 2024 Celeste Sinéad + +import unittest + +import numpy as np + +from friture.reference_curves import ( + DEFAULT_REFERENCE_OFFSET_DB, + REFERENCE_A_WEIGHT, + REFERENCE_FLAT, + REFERENCE_HOUSE, + REFERENCE_NONE, + REFERENCE_PINK, + REFERENCE_PRESET_NAMES, + a_weighting_db, + reference_curve_db, +) + + +class ReferenceCurvesTest(unittest.TestCase): + def test_none_returns_none(self) -> None: + freqs = np.array([100.0, 1000.0, 10000.0]) + + self.assertIsNone(reference_curve_db(REFERENCE_NONE, freqs)) + + def test_flat_is_zero_before_offset(self) -> None: + freqs = np.array([50.0, 1000.0, 20000.0]) + + curve = reference_curve_db(REFERENCE_FLAT, freqs, display_mode="fft") + + np.testing.assert_allclose(curve, [0.0, 0.0, 0.0]) + + def test_offset_shifts_curve(self) -> None: + freqs = np.array([1000.0]) + + curve = reference_curve_db( + REFERENCE_FLAT, + freqs, + offset_db=12.5, + display_mode="octave", + ) + + self.assertAlmostEqual(curve[0], 12.5) + + def test_pink_fft_rises_with_frequency(self) -> None: + freqs = np.array([100.0, 1000.0, 10000.0]) + + curve = reference_curve_db(REFERENCE_PINK, freqs, display_mode="fft") + + self.assertAlmostEqual(curve[1], 0.0, places=1) + self.assertLess(curve[0], curve[1]) + self.assertGreater(curve[2], curve[1]) + self.assertAlmostEqual(curve[2] - curve[0], 20.0, delta=0.5) + + def test_pink_octave_is_flat(self) -> None: + freqs = np.array([125.0, 1000.0, 8000.0]) + + curve = reference_curve_db(REFERENCE_PINK, freqs, display_mode="octave") + + np.testing.assert_allclose(curve, [0.0, 0.0, 0.0]) + + def test_a_weighting_anchored_at_one_khz(self) -> None: + freqs = np.array([100.0, 1000.0, 10000.0]) + + curve = reference_curve_db(REFERENCE_A_WEIGHT, freqs, display_mode="fft") + + self.assertAlmostEqual(curve[1], 0.0, places=1) + self.assertLess(curve[0], curve[1]) + self.assertLess(curve[2], curve[1]) + + def test_house_rolls_off_above_two_khz(self) -> None: + freqs = np.array([1000.0, 2000.0, 4000.0, 8000.0]) + + curve = reference_curve_db(REFERENCE_HOUSE, freqs, display_mode="fft") + + self.assertAlmostEqual(curve[0], 0.0) + self.assertAlmostEqual(curve[1], 0.0) + self.assertLess(curve[2], 0.0) + self.assertLess(curve[3], curve[2]) + + def test_a_weighting_matches_audioproc_formula(self) -> None: + freqs = np.array([500.0, 1000.0, 2000.0]) + ra = ( + 12200.0**2 + * freqs**4 + / ( + (freqs**2 + 20.6**2) + * (freqs**2 + 12200.0**2) + * np.sqrt(freqs**2 + 107.7**2) + * np.sqrt(freqs**2 + 737.9**2) + ) + ) + expected = 2.0 + 20.0 * np.log10(ra + 1e-50) + + np.testing.assert_allclose(a_weighting_db(freqs), expected) + + def test_preset_names_cover_all_presets(self) -> None: + self.assertEqual(len(REFERENCE_PRESET_NAMES), 5) + self.assertEqual(DEFAULT_REFERENCE_OFFSET_DB, 0.0) 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 + + +