From 3c5190b43f469f4fde0297efad1d86ae5d746f44 Mon Sep 17 00:00:00 2001 From: Donavin97 Date: Tue, 17 Jun 2025 19:18:51 +0000 Subject: [PATCH 01/23] Fixed error handling. added: xmlStructuredErrorFunc handler = xmlStructuredErrorHandler; xmlStructuredErrorFunc(nullptr, handler); Fixes compilation errors when building against latest libxml2. --- libs/seiscomp/io/archive/xmlarchive.cpp | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/libs/seiscomp/io/archive/xmlarchive.cpp b/libs/seiscomp/io/archive/xmlarchive.cpp index 6a9ad608e..911f205fb 100644 --- a/libs/seiscomp/io/archive/xmlarchive.cpp +++ b/libs/seiscomp/io/archive/xmlarchive.cpp @@ -484,7 +484,8 @@ void XMLArchive::close() { _forceWriteVersion = -1; - initGenericErrorDefaultFunc(nullptr); + xmlStructuredErrorFunc handler = xmlStructuredErrorHandler; + xmlStructuredErrorFunc(nullptr, handler); setVersion(Core::Version(0,0)); } From 5e201564ce5716ad303a2b9cac00f028fe76b185 Mon Sep 17 00:00:00 2001 From: Donavin97 Date: Sat, 12 Jul 2025 09:17:31 +0000 Subject: [PATCH 02/23] xml fix --- libs/seiscomp/io/archive/xmlarchive.cpp | 1 - 1 file changed, 1 deletion(-) diff --git a/libs/seiscomp/io/archive/xmlarchive.cpp b/libs/seiscomp/io/archive/xmlarchive.cpp index 758ff21af..f92df86a6 100644 --- a/libs/seiscomp/io/archive/xmlarchive.cpp +++ b/libs/seiscomp/io/archive/xmlarchive.cpp @@ -485,7 +485,6 @@ void XMLArchive::close() { _forceWriteVersion = -1; xmlSetGenericErrorFunc(nullptr, nullptr); ->>>>>>> e70e4b6f8230d5953559693c2400c4dedccde285 setVersion(Core::Version(0,0)); } From 388ba73453ff8a1c267c31b9288925c7b5325773 Mon Sep 17 00:00:00 2001 From: Jan Becker Date: Tue, 15 Jul 2025 12:06:07 +0200 Subject: [PATCH 03/23] [gui] Compute amplitude announcement frequency in PickerView --- libs/seiscomp/gui/datamodel/pickerview.cpp | 104 ++++++++++++++++++--- libs/seiscomp/gui/datamodel/pickerview.h | 1 + 2 files changed, 90 insertions(+), 15 deletions(-) diff --git a/libs/seiscomp/gui/datamodel/pickerview.cpp b/libs/seiscomp/gui/datamodel/pickerview.cpp index 927877f0c..5929fe33c 100644 --- a/libs/seiscomp/gui/datamodel/pickerview.cpp +++ b/libs/seiscomp/gui/datamodel/pickerview.cpp @@ -6571,11 +6571,85 @@ void PickerView::updateSubCursor(RecordWidget* w, int s) { } } - if ( !SC_D.recordView->currentItem() ) return; + if ( !SC_D.recordView->currentItem() ) { + return; + } SC_D.recordView->currentItem()->widget()->blockSignals(true); SC_D.recordView->currentItem()->widget()->setCursorPos(w->cursorPos()); SC_D.recordView->currentItem()->widget()->blockSignals(false); + + if ( true ) { + announceAmplitude(); + } +} +// <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<< + + + + +// >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> +void PickerView::announceAmplitude() { + bool gotAmplitude = false; + double amplitude; + + auto seq = + SC_D.currentRecord->isFilteringEnabled() + ? + SC_D.currentRecord->filteredRecords(SC_D.currentSlot) + : + SC_D.currentRecord->records(SC_D.currentSlot); + + if ( seq ) { + auto it = seq->lowerBound(SC_D.currentRecord->cursorPos()); + if ( it != seq->end() ) { + auto rec = *it; + if ( rec->data() ) { + int pos = static_cast(static_cast(SC_D.currentRecord->cursorPos() - rec->startTime()) * rec->samplingFrequency()); + auto dArray = DoubleArray::ConstCast(rec->data()); + if ( dArray ) { + if ( (pos >= 0) && (pos < dArray->size()) ) { + amplitude = (*dArray)[pos]; + if ( SC_D.currentRecord->areScaledValuesShown() ) { + amplitude *= *SC_D.currentRecord->recordScale(SC_D.currentSlot); + } + gotAmplitude = true; + } + } + else { + auto fArray = FloatArray::ConstCast(rec->data()); + if ( fArray ) { + if ( (pos >= 0) && (pos < fArray->size()) ) { + amplitude = static_cast((*fArray)[pos]); + if ( SC_D.currentRecord->areScaledValuesShown() ) { + amplitude *= *SC_D.currentRecord->recordScale(SC_D.currentSlot); + } + gotAmplitude = true; + } + } + } + } + } + } + + if ( !gotAmplitude ) { + // TODO: Stop playback + qDebug() << "-1"; + } + else { + auto range = SC_D.currentRecord->amplitudeDataRange(SC_D.currentSlot); + auto width = range.second - range.first; + auto level = width != 0.0 ? (amplitude - range.first) / width : 0.5; + // Level is from 0 to 1 where 0 is the lower end of the amplitude range + // and 1 is the upper end. 0.5 is the center of the view but not + // necessarily the data offset. + + const double lowerFrequency = 440; + const double upperFrequency = 880; + double frequency = (upperFrequency - lowerFrequency) * level + lowerFrequency; + // TODO: Start playback of frequency + qDebug() << frequency; + } } // <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<< @@ -6693,7 +6767,9 @@ void PickerView::updateItemRecordState(const Seiscomp::Record *rec) { void PickerView::setCursorPos(const Seiscomp::Core::Time& t, bool always) { SC_D.currentRecord->setCursorPos(t); - if ( !always && SC_D.currentRecord->cursorText() == "" ) return; + if ( !always && SC_D.currentRecord->cursorText() == "" ) { + return; + } double offset = 0; @@ -7049,23 +7125,21 @@ void PickerView::itemSelected(RecordViewItem* item, RecordViewItem* lastItem) { } } - if ( cha.size() > 2 ) + if ( cha.size() > 2 ) { cha[cha.size()-1] = component; - else + } + else { cha += component; + } SC_D.ui.labelStationCode->setText(streamID.stationCode().c_str()); - SC_D.ui.labelCode->setText(QString("%1 %2%3") - .arg(streamID.networkCode().c_str(), - streamID.locationCode().c_str(), - cha.c_str())); - /* - const RecordSequence* seq = SC_D.currentRecord->records(); - if ( seq && !seq->empty() ) - SC_D.ui.labelCode->setText((*seq->begin())->streamID().c_str()); - else - SC_D.ui.labelCode->setText("NO DATA"); - */ + SC_D.ui.labelStationCode->setAccessibleName(streamID.stationCode().c_str()); + SC_D.ui.labelStationCode->setTextInteractionFlags(Qt::TextBrowserInteraction); + SC_D.ui.labelCode->setText(QString("%1 %2%3").arg( + streamID.networkCode().c_str(), + streamID.locationCode().c_str(), + cha.c_str()) + ); PickerRecordLabel *label = static_cast(item->label()); static_cast(SC_D.currentRecord)->setTraces(label->data.traces); diff --git a/libs/seiscomp/gui/datamodel/pickerview.h b/libs/seiscomp/gui/datamodel/pickerview.h index 9b53ad68e..7f018ee6a 100644 --- a/libs/seiscomp/gui/datamodel/pickerview.h +++ b/libs/seiscomp/gui/datamodel/pickerview.h @@ -565,6 +565,7 @@ class SC_GUI_API PickerView : public QMainWindow { private: + void announceAmplitude(); void figureOutTravelTimeTable(); void updateTransformations(PrivatePickerView::PickerRecordLabel *label); From 91e91cbd32f62fa9c7a1bd960e4c2d8674f17e8f Mon Sep 17 00:00:00 2001 From: Donavin97 Date: Thu, 17 Jul 2025 07:35:53 +0000 Subject: [PATCH 04/23] Added ampLabel to read out amplitude when arrow keys are used in picking mode. --- libs/seiscomp/gui/datamodel/pickerview.cpp | 2 ++ libs/seiscomp/gui/datamodel/pickerview.ui | 13 +++++++++++++ 2 files changed, 15 insertions(+) diff --git a/libs/seiscomp/gui/datamodel/pickerview.cpp b/libs/seiscomp/gui/datamodel/pickerview.cpp index 5929fe33c..597e0e42e 100644 --- a/libs/seiscomp/gui/datamodel/pickerview.cpp +++ b/libs/seiscomp/gui/datamodel/pickerview.cpp @@ -6649,6 +6649,8 @@ void PickerView::announceAmplitude() { double frequency = (upperFrequency - lowerFrequency) * level + lowerFrequency; // TODO: Start playback of frequency qDebug() << frequency; + SC_D.ui.labelCurrentAmp->setAccessibleName(QString::number(amplitude)); + SC_D.ui.labelCurrentAmp->setTextInteractionFlags(Qt::TextBrowserInteraction); } } // <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<< diff --git a/libs/seiscomp/gui/datamodel/pickerview.ui b/libs/seiscomp/gui/datamodel/pickerview.ui index b27aa6e48..5891974ab 100644 --- a/libs/seiscomp/gui/datamodel/pickerview.ui +++ b/libs/seiscomp/gui/datamodel/pickerview.ui @@ -109,6 +109,19 @@ 0 + + + + + 0 + 0 + + + + ABCD + + + From aac8f6eb0e8c7c6d847acb2920d5cbc6d7be6e46 Mon Sep 17 00:00:00 2001 From: Donavin97 Date: Thu, 17 Jul 2025 08:53:42 +0000 Subject: [PATCH 05/23] The CurrentAmp label now reads the frequencies of the normalised amplitudes. --- libs/seiscomp/gui/datamodel/pickerview.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/seiscomp/gui/datamodel/pickerview.cpp b/libs/seiscomp/gui/datamodel/pickerview.cpp index 597e0e42e..11a895fc3 100644 --- a/libs/seiscomp/gui/datamodel/pickerview.cpp +++ b/libs/seiscomp/gui/datamodel/pickerview.cpp @@ -6649,7 +6649,7 @@ void PickerView::announceAmplitude() { double frequency = (upperFrequency - lowerFrequency) * level + lowerFrequency; // TODO: Start playback of frequency qDebug() << frequency; - SC_D.ui.labelCurrentAmp->setAccessibleName(QString::number(amplitude)); + SC_D.ui.labelCurrentAmp->setAccessibleName(QString::number(frequency)); SC_D.ui.labelCurrentAmp->setTextInteractionFlags(Qt::TextBrowserInteraction); } } From 657aad5d3ae46e0671004efc3ae6020561cf9e29 Mon Sep 17 00:00:00 2001 From: Donavin97 Date: Thu, 17 Jul 2025 16:26:10 +0000 Subject: [PATCH 06/23] Implemented audable picker. --- libs/seiscomp/gui/datamodel/pickerview.cpp | 10 ++++++++-- libs/seiscomp/gui/datamodel/pickerview.ui | 19 +++++++++++++++++++ 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/libs/seiscomp/gui/datamodel/pickerview.cpp b/libs/seiscomp/gui/datamodel/pickerview.cpp index 11a895fc3..8e11a30b2 100644 --- a/libs/seiscomp/gui/datamodel/pickerview.cpp +++ b/libs/seiscomp/gui/datamodel/pickerview.cpp @@ -6644,13 +6644,19 @@ void PickerView::announceAmplitude() { // and 1 is the upper end. 0.5 is the center of the view but not // necessarily the data offset. - const double lowerFrequency = 440; - const double upperFrequency = 880; + const double lowerFrequency = 440.0; + const double upperFrequency = 880.0; double frequency = (upperFrequency - lowerFrequency) * level + lowerFrequency; + double percent = 100.0 * level; + QString format = tr("%1 %").arg(QString::number(percent, 'f', 1)); + // TODO: Start playback of frequency qDebug() << frequency; SC_D.ui.labelCurrentAmp->setAccessibleName(QString::number(frequency)); SC_D.ui.labelCurrentAmp->setTextInteractionFlags(Qt::TextBrowserInteraction); + SC_D.ui.ampProgress->setRange(0, 100); + SC_D.ui.ampProgress->setFormat(format); + SC_D.ui.ampProgress->setValue(static_cast(percent)); } } // <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<< diff --git a/libs/seiscomp/gui/datamodel/pickerview.ui b/libs/seiscomp/gui/datamodel/pickerview.ui index 5891974ab..6e224d294 100644 --- a/libs/seiscomp/gui/datamodel/pickerview.ui +++ b/libs/seiscomp/gui/datamodel/pickerview.ui @@ -122,6 +122,25 @@ + + + + + 0 + 0 + + + + 0 + + + 100 + + + + + + From b0e9f3f5c7766e4ed9cf128cc6dfdfd033203672 Mon Sep 17 00:00:00 2001 From: Donavin97 Date: Sat, 6 Jun 2026 14:27:20 +0000 Subject: [PATCH 07/23] PickerView accessibility improvements: screen reader, audio sonification, context menus - Screen reader announcements for amplitude, SNR, pick details at cursor - Announce markers when navigating between picks (Alt+Left/Right) - Ctrl+Shift+D announces detailed pick info for current position - Audio sonification of seismic waveforms via WAV file + system aplay/paplay - Ctrl+Shift+A toggles sonification, Ctrl+Space plays full trace at 160x - Interactive cursor-tracking audio: 15s window at 80x on cursor stop (300ms debounce) - Ctrl+Shift+Space stops playback - DC offset removal and absolute-max normalization for clean audio - Keyboard-accessible context menus (Menu key) for picks with polarity/onset/uncertainty - Fixed context menu marker lookup via nearestMarker fallback when cursor text is stale - Fresh QActions per context menu instance to avoid state corruption from shared actions --- libs/seiscomp/gui/core/CMakeLists.txt | 3 + libs/seiscomp/gui/core/waveformaudio.cpp | 319 ++++ libs/seiscomp/gui/core/waveformaudio.h | 94 ++ libs/seiscomp/gui/datamodel/CMakeLists.txt | 2 + libs/seiscomp/gui/datamodel/amplitudeview.cpp | 44 - libs/seiscomp/gui/datamodel/amplitudeview.h | 2 - libs/seiscomp/gui/datamodel/amplitudeview.ui | 116 +- libs/seiscomp/gui/datamodel/pickerview.cpp | 1426 ++++++++++++++++- libs/seiscomp/gui/datamodel/pickerview.h | 16 +- libs/seiscomp/gui/datamodel/pickerview.ui | 381 ++++- .../datamodel/pickerview_accessibility.cpp | 541 +++++++ .../gui/datamodel/pickerview_accessibility.h | 168 ++ libs/seiscomp/gui/datamodel/pickerview_p.h | 5 + 13 files changed, 2860 insertions(+), 257 deletions(-) create mode 100644 libs/seiscomp/gui/core/waveformaudio.cpp create mode 100644 libs/seiscomp/gui/core/waveformaudio.h create mode 100644 libs/seiscomp/gui/datamodel/pickerview_accessibility.cpp create mode 100644 libs/seiscomp/gui/datamodel/pickerview_accessibility.h diff --git a/libs/seiscomp/gui/core/CMakeLists.txt b/libs/seiscomp/gui/core/CMakeLists.txt index 28752bc67..4122f21b5 100644 --- a/libs/seiscomp/gui/core/CMakeLists.txt +++ b/libs/seiscomp/gui/core/CMakeLists.txt @@ -36,6 +36,7 @@ SET( uncertainties.cpp utils.cpp vruler.cpp + waveformaudio.cpp xmlview.cpp ) @@ -63,6 +64,7 @@ SET( spectrogramrenderer.h tensorrenderer.h utils.h + waveformaudio.h ) SET( @@ -92,6 +94,7 @@ SET( timescale.h uncertainties.h vruler.h + waveformaudio.h xmlview.h ) diff --git a/libs/seiscomp/gui/core/waveformaudio.cpp b/libs/seiscomp/gui/core/waveformaudio.cpp new file mode 100644 index 000000000..c170d8c08 --- /dev/null +++ b/libs/seiscomp/gui/core/waveformaudio.cpp @@ -0,0 +1,319 @@ +/*************************************************************************** + * Copyright (C) gempa GmbH * + * All rights reserved. * + * Contact: gempa GmbH (seiscomp-dev@gempa.de) * + * * + * GNU Affero General Public License Usage * + * This file may be used under the terms of the GNU Affero * + * Public License version 3.0 as published by the Free Software Foundation * + * and appearing in the file LICENSE included in the packaging of this * + * file. Please review the following information to ensure the GNU Affero * + * Public License version 3.0 requirements will be met: * + * https://www.gnu.org/licenses/agpl-3.0.html. * + * * + * Other Usage * + * Alternatively, this file may be used in accordance with the terms and * + * conditions contained in a signed written agreement between you and * + * gempa GmbH. * + ***************************************************************************/ + + +#include "waveformaudio.h" + +#include +#include +#include +#include +#include + + +namespace Seiscomp { +namespace Gui { + + +WaveformAudio::WaveformAudio(QObject *parent) +: QObject(parent) { +} + +WaveformAudio::~WaveformAudio() { + stop(); +} + +void WaveformAudio::setWaveformData(const std::vector &data, + double originalSampleRate, + float speedFactor) { + stop(); + + _waveformData = data; + _originalSampleRate = originalSampleRate; + _speedFactor = speedFactor; + _audioSamples.clear(); + _audioDurationMs = 0; + _dataDurationSec = 0.0; + + if ( _waveformData.empty() || _originalSampleRate <= 0.0 ) { + return; + } + + _dataDurationSec = static_cast(_waveformData.size()) / _originalSampleRate; + + double mean = 0.0; + for ( double val : _waveformData ) { + mean += val; + } + mean /= static_cast(_waveformData.size()); + + double maxVal = 0.0; + for ( double val : _waveformData ) { + double absVal = std::abs(val - mean); + if ( absVal > maxVal ) maxVal = absVal; + } + + if ( maxVal <= 0.0 ) { + return; + } + + int dataSize = static_cast(_waveformData.size()); + double effectiveRate = _originalSampleRate * static_cast(_speedFactor); + double sampleRatio = effectiveRate / static_cast(AUDIO_OUTPUT_RATE); + + int totalAudioSamples = static_cast( + static_cast(dataSize) / sampleRatio + ); + + _audioSamples.reserve(totalAudioSamples); + + double currentPos = 0.0; + for ( int i = 0; i < totalAudioSamples; ++i ) { + if ( currentPos >= static_cast(dataSize - 1) ) { + _audioSamples.push_back(0.0f); + currentPos += sampleRatio; + continue; + } + + int idx0 = static_cast(currentPos); + int idx1 = idx0 + 1; + if ( idx1 >= dataSize ) idx1 = dataSize - 1; + + double frac = currentPos - static_cast(idx0); + double sample = (_waveformData[idx0] - mean) * (1.0 - frac) + + (_waveformData[idx1] - mean) * frac; + + sample /= maxVal; + + if ( sample > 1.0 ) sample = 1.0; + if ( sample < -1.0 ) sample = -1.0; + + _audioSamples.push_back(static_cast(sample)); + + currentPos += sampleRatio; + } + + _audioDurationMs = static_cast( + (static_cast(_audioSamples.size()) / + static_cast(AUDIO_OUTPUT_RATE)) * 1000.0 + ); +} + +bool WaveformAudio::isPlaying() const { + return _playing; +} + +bool WaveformAudio::isEnabled() const { + return _enabled; +} + +void WaveformAudio::setEnabled(bool enabled) { + if ( _enabled != enabled ) { + _enabled = enabled; + if ( !_enabled ) { + stop(); + } + } +} + +float WaveformAudio::speedFactor() const { + return _speedFactor; +} + +void WaveformAudio::setSpeedFactor(float factor) { + if ( factor > 0.0f ) { + _speedFactor = factor; + } +} + +int WaveformAudio::audioDurationMs() const { + return _audioDurationMs; +} + +double WaveformAudio::dataDurationSec() const { + return _dataDurationSec; +} + +void WaveformAudio::play() { + if ( !_enabled || _audioSamples.empty() || _playing ) { + return; + } + + QTemporaryFile *tmpFile = new QTemporaryFile( + QStringLiteral("/tmp/seiscomp_audio_XXXXXX.wav"), this + ); + tmpFile->setAutoRemove(true); + + if ( !tmpFile->open() ) { + delete tmpFile; + emit playbackError(QStringLiteral("Cannot create temporary file for audio")); + return; + } + + QString filePath = tmpFile->fileName(); + tmpFile->close(); + + if ( !generateWavFile(filePath) ) { + emit playbackError(QStringLiteral("Cannot write audio data to file")); + return; + } + + _process = new QProcess(this); + connect(_process, + QOverload::of(&QProcess::finished), + this, &WaveformAudio::onProcessFinished); + connect(_process, &QProcess::errorOccurred, + this, &WaveformAudio::onProcessErrorOccurred); + + QStringList args; + args << filePath; + + QString program = QStringLiteral("aplay"); + _process->start(program, args); + + if ( _process->waitForStarted(3000) ) { + _playing = true; + emit playbackStarted(); + } + else { + delete _process; + _process = nullptr; + + program = QStringLiteral("paplay"); + _process = new QProcess(this); + connect(_process, + QOverload::of(&QProcess::finished), + this, &WaveformAudio::onProcessFinished); + connect(_process, &QProcess::errorOccurred, + this, &WaveformAudio::onProcessErrorOccurred); + + _process->start(program, args); + + if ( _process->waitForStarted(3000) ) { + _playing = true; + emit playbackStarted(); + } + else { + delete _process; + _process = nullptr; + emit playbackError(QStringLiteral("Cannot start audio player (aplay or paplay)")); + } + } +} + +void WaveformAudio::stop() { + if ( _process ) { + _process->kill(); + _process->waitForFinished(1000); + delete _process; + _process = nullptr; + } + _playing = false; +} + +bool WaveformAudio::generateWavFile(const QString &filePath) { + QFile file(filePath); + if ( !file.open(QIODevice::WriteOnly) ) { + return false; + } + + int numSamples = static_cast(_audioSamples.size()); + int dataSize = numSamples * 2; + + if ( !writeWavHeader(file, dataSize) ) { + return false; + } + + QByteArray buffer; + buffer.reserve(dataSize); + for ( int i = 0; i < numSamples; ++i ) { + float sample = _audioSamples[i]; + if ( sample > 1.0f ) sample = 1.0f; + if ( sample < -1.0f ) sample = -1.0f; + int16_t pcm = static_cast(sample * 32767.0f); + buffer.append(static_cast(pcm & 0xFF)); + buffer.append(static_cast((pcm >> 8) & 0xFF)); + } + + file.write(buffer); + file.close(); + return true; +} + +bool WaveformAudio::writeWavHeader(QIODevice &device, int dataSize) { + struct WavHeader { + char riffId[4]; + uint32_t fileSize; + char waveId[4]; + char fmtId[4]; + uint32_t fmtSize; + uint16_t audioFormat; + uint16_t numChannels; + uint32_t sampleRate; + uint32_t byteRate; + uint16_t blockAlign; + uint16_t bitsPerSample; + char dataId[4]; + uint32_t dataSize; + } header; + + std::memcpy(header.riffId, "RIFF", 4); + header.fileSize = 36 + dataSize; + std::memcpy(header.waveId, "WAVE", 4); + std::memcpy(header.fmtId, "fmt ", 4); + header.fmtSize = 16; + header.audioFormat = 1; + header.numChannels = 1; + header.sampleRate = AUDIO_OUTPUT_RATE; + header.byteRate = AUDIO_OUTPUT_RATE * 2; + header.blockAlign = 2; + header.bitsPerSample = 16; + std::memcpy(header.dataId, "data", 4); + header.dataSize = dataSize; + + device.write(reinterpret_cast(&header), sizeof(header)); + return true; +} + +void WaveformAudio::onProcessFinished(int exitCode, QProcess::ExitStatus status) { + Q_UNUSED(exitCode) + Q_UNUSED(status) + + if ( _process ) { + delete _process; + _process = nullptr; + } + + _playing = false; + emit playbackFinished(); +} + +void WaveformAudio::onProcessErrorOccurred(QProcess::ProcessError error) { + if ( _process && error == QProcess::FailedToStart ) { + QString err = _process->errorString(); + delete _process; + _process = nullptr; + _playing = false; + emit playbackError(err); + } +} + + +} +} diff --git a/libs/seiscomp/gui/core/waveformaudio.h b/libs/seiscomp/gui/core/waveformaudio.h new file mode 100644 index 000000000..954f5cec0 --- /dev/null +++ b/libs/seiscomp/gui/core/waveformaudio.h @@ -0,0 +1,94 @@ +/*************************************************************************** + * Copyright (C) gempa GmbH * + * All rights reserved. * + * Contact: gempa GmbH (seiscomp-dev@gempa.de) * + * * + * GNU Affero General Public License Usage * + * This file may be used under the terms of the GNU Affero * + * Public License version 3.0 as published by the Free Software Foundation * + * and appearing in the file LICENSE included in the packaging of this * + * file. Please review the following information to ensure the GNU Affero * + * Public License version 3.0 requirements will be met: * + * https://www.gnu.org/licenses/agpl-3.0.html. * + * * + * Other Usage * + * Alternatively, this file may be used in accordance with the terms and * + * conditions contained in a signed written agreement between you and * + * gempa GmbH. * + ***************************************************************************/ + + +#ifndef SEISCOMP_GUI_WAVEFORMAUDIO_H +#define SEISCOMP_GUI_WAVEFORMAUDIO_H + + +#include +#include +#include + +#include + + +namespace Seiscomp { +namespace Gui { + + +class SC_GUI_API WaveformAudio : public QObject { + Q_OBJECT + + public: + WaveformAudio(QObject *parent = nullptr); + ~WaveformAudio(); + + public: + void setWaveformData(const std::vector &data, + double originalSampleRate, + float speedFactor = 160.0f); + + bool isPlaying() const; + bool isEnabled() const; + void setEnabled(bool enabled); + + float speedFactor() const; + void setSpeedFactor(float factor); + + int audioDurationMs() const; + double dataDurationSec() const; + + public slots: + void play(); + void stop(); + + signals: + void playbackStarted(); + void playbackFinished(); + void playbackError(const QString &error); + + private: + bool generateWavFile(const QString &filePath); + bool writeWavHeader(QIODevice &device, int dataSize); + + private slots: + void onProcessFinished(int exitCode, QProcess::ExitStatus status); + void onProcessErrorOccurred(QProcess::ProcessError error); + + private: + std::vector _waveformData; + std::vector _audioSamples; + double _originalSampleRate{0.0}; + float _speedFactor{160.0f}; + int _audioDurationMs{0}; + double _dataDurationSec{0.0}; + bool _enabled{false}; + bool _playing{false}; + QProcess *_process{nullptr}; + + static constexpr int AUDIO_OUTPUT_RATE = 44100; +}; + + +} +} + + +#endif diff --git a/libs/seiscomp/gui/datamodel/CMakeLists.txt b/libs/seiscomp/gui/datamodel/CMakeLists.txt index 2365c66e9..63b9682f5 100644 --- a/libs/seiscomp/gui/datamodel/CMakeLists.txt +++ b/libs/seiscomp/gui/datamodel/CMakeLists.txt @@ -15,6 +15,7 @@ SET(GUI_DATAMODEL_SOURCES origintime.cpp origindialog.cpp pickerview.cpp + pickerview_accessibility.cpp pickerzoomframe.cpp pickersettings.cpp amplitudeview.cpp @@ -36,6 +37,7 @@ SET(GUI_DATAMODEL_HEADERS tensorsymbol.h ttdecorator.h utils.h + pickerview_accessibility.h ) SET(GUI_DATAMODEL_MOC_HEADERS diff --git a/libs/seiscomp/gui/datamodel/amplitudeview.cpp b/libs/seiscomp/gui/datamodel/amplitudeview.cpp index 99f12f9b5..3f0dfb725 100644 --- a/libs/seiscomp/gui/datamodel/amplitudeview.cpp +++ b/libs/seiscomp/gui/datamodel/amplitudeview.cpp @@ -2125,8 +2125,6 @@ void AmplitudeView::init() { addAction(SC_D.ui.actionAlignOnPArrival); addAction(SC_D.ui.actionToggleFilter); - addAction(SC_D.ui.actionNextFilter); - addAction(SC_D.ui.actionPreviousFilter); addAction(SC_D.ui.actionMaximizeAmplitudes); addAction(SC_D.ui.actionCreateAmplitude); @@ -2161,8 +2159,6 @@ void AmplitudeView::init() { SC_D.comboFilter->addItem(DEFAULT_FILTER_STRING); SC_D.comboFilter->setCurrentIndex(1); SC_D.ui.actionToggleFilter->setEnabled(false); - SC_D.ui.actionNextFilter->setEnabled(false); - SC_D.ui.actionPreviousFilter->setEnabled(false); changeFilter(SC_D.comboFilter->currentIndex()); SC_D.spinSNR = new QDoubleSpinBox; @@ -2335,10 +2331,6 @@ void AmplitudeView::init() { connect(SC_D.ui.actionToggleFilter, SIGNAL(triggered(bool)), this, SLOT(toggleFilter())); - connect(SC_D.ui.actionNextFilter, SIGNAL(triggered(bool)), - this, SLOT(nextFilter())); - connect(SC_D.ui.actionPreviousFilter, SIGNAL(triggered(bool)), - this, SLOT(previousFilter())); connect(SC_D.ui.actionMaximizeAmplitudes, SIGNAL(triggered(bool)), this, SLOT(scaleVisibleAmplitudes())); @@ -2515,8 +2507,6 @@ bool AmplitudeView::setConfig(const Config &c, QString *error) { SC_D.comboFilter->blockSignals(false); SC_D.comboFilter->setCurrentIndex(defaultIndex != -1 ? defaultIndex : 1); SC_D.ui.actionToggleFilter->setEnabled(!SC_D.config.filters.empty()); - SC_D.ui.actionNextFilter->setEnabled(!SC_D.config.filters.empty()); - SC_D.ui.actionPreviousFilter->setEnabled(!SC_D.config.filters.empty()); } RecordViewItem *item = SC_D.recordView->currentItem(); @@ -5431,40 +5421,6 @@ void AmplitudeView::toggleFilter() { -// >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> -void AmplitudeView::nextFilter() { - // Filtering turned off - int idx = SC_D.comboFilter->currentIndex(); - if ( idx == 0 ) return; - - ++idx; - if ( idx >= SC_D.comboFilter->count() ) - idx = 1; - - SC_D.comboFilter->setCurrentIndex(idx); -} -// <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<< - - - - -// >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> -void AmplitudeView::previousFilter() { - // Filtering turned off - int idx = SC_D.comboFilter->currentIndex(); - if ( idx == 0 ) return; - - --idx; - if ( idx < 1 ) - idx = SC_D.comboFilter->count()-1; - - SC_D.comboFilter->setCurrentIndex(idx); -} -// <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<< - - - - // >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> void AmplitudeView::addNewFilter(const QString& filter) { SC_D.lastFilterIndex = SC_D.comboFilter->findData(filter); diff --git a/libs/seiscomp/gui/datamodel/amplitudeview.h b/libs/seiscomp/gui/datamodel/amplitudeview.h index 0281c461b..280e2fc79 100644 --- a/libs/seiscomp/gui/datamodel/amplitudeview.h +++ b/libs/seiscomp/gui/datamodel/amplitudeview.h @@ -309,8 +309,6 @@ class SC_GUI_API AmplitudeView : public QMainWindow { void limitFilterToZoomTrace(bool); void toggleFilter(); - void nextFilter(); - void previousFilter(); void addNewFilter(const QString&); void scaleVisibleAmplitudes(); diff --git a/libs/seiscomp/gui/datamodel/amplitudeview.ui b/libs/seiscomp/gui/datamodel/amplitudeview.ui index 1e948073f..bb0b5f756 100644 --- a/libs/seiscomp/gui/datamodel/amplitudeview.ui +++ b/libs/seiscomp/gui/datamodel/amplitudeview.ui @@ -6,8 +6,8 @@ 0 0 - 1217 - 874 + 917 + 690 @@ -24,16 +24,7 @@ 6 - - 6 - - - 6 - - - 6 - - + 6 @@ -52,16 +43,7 @@ 6 - - 0 - - - 0 - - - 0 - - + 0 @@ -82,16 +64,7 @@ 6 - - 6 - - - 6 - - - 6 - - + 6 @@ -112,16 +85,7 @@ 6 - - 0 - - - 0 - - - 0 - - + 0 @@ -142,16 +106,7 @@ 0 - - 0 - - - 0 - - - 0 - - + 0 @@ -240,16 +195,7 @@ 6 - - 0 - - - 0 - - - 0 - - + 0 @@ -270,16 +216,7 @@ 6 - - 0 - - - 0 - - - 0 - - + 0 @@ -562,8 +499,8 @@ 0 0 - 1217 - 30 + 917 + 38 @@ -574,7 +511,7 @@ &Zoom trace - + @@ -634,8 +571,8 @@ - - + + @@ -644,8 +581,6 @@ &Filter - - @@ -1291,28 +1226,6 @@ W - - - Next filter - - - Next filter - - - G - - - - - Previous filter - - - Previous filter - - - D - - @@ -1322,6 +1235,5 @@ 1 - diff --git a/libs/seiscomp/gui/datamodel/pickerview.cpp b/libs/seiscomp/gui/datamodel/pickerview.cpp index 1de6a2297..5e7e896a0 100644 --- a/libs/seiscomp/gui/datamodel/pickerview.cpp +++ b/libs/seiscomp/gui/datamodel/pickerview.cpp @@ -23,6 +23,8 @@ #include "pickerview.h" #include "pickerview_p.h" +#include "pickerview_accessibility.h" +#include #include #include @@ -55,6 +57,7 @@ #include #include #include +#include #include #include #include @@ -65,6 +68,12 @@ #include #include #include +#include +#include +#if QT_VERSION >= QT_VERSION_CHECK(6, 8, 0) +#include +#endif +#include #include #include @@ -139,6 +148,254 @@ PickerView::Config::UnitType fromGainUnit(const std::string &gainUnit) { } +// >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> +//! Compute amplitude and SNR for a pick at given time from current record data +//! Returns true if successful, fills amplitude, snr, and quality parameters +static bool computePickAmplitudeAndSNR(RecordWidget* widget, const Core::Time& pickTime, + double& outAmplitude, double& outSNR, QString& outQuality) { + if ( !widget ) return false; + + int slot = widget->currentRecords(); + if ( slot < 0 ) return false; + + auto seq = widget->isFilteringEnabled() + ? widget->filteredRecords(slot) + : widget->records(slot); + + if ( !seq || seq->empty() ) return false; + + // Find record containing pick time + auto it = seq->lowerBound(pickTime); + if ( it == seq->end() ) return false; + + auto rec = *it; + if ( !rec || !rec->data() ) return false; + + double fsamp = rec->samplingFrequency(); + if ( fsamp <= 0 ) return false; + + int pickSample = static_cast((pickTime - rec->startTime()).length() * fsamp); + if ( pickSample < 0 || pickSample >= rec->sampleCount() ) return false; + + // Get data array + const double* data = nullptr; + auto dArray = DoubleArray::ConstCast(rec->data()); + if ( dArray && pickSample < dArray->size() ) { + data = dArray->typedData(); + } else { + auto fArray = FloatArray::ConstCast(rec->data()); + if ( fArray && pickSample < fArray->size() ) { + // Use float data directly + const float* fdata = fArray->typedData(); + outAmplitude = std::abs(fdata[pickSample]); + } else { + return false; + } + } + + if ( data ) { + outAmplitude = std::abs(data[pickSample]); + } + + // Compute SNR using pre/post windows + int preSamples = static_cast(5.0 * fsamp); // 5 seconds noise window + int postSamples = static_cast(10.0 * fsamp); // 10 seconds signal window + + int noiseStart = std::max(0, pickSample - preSamples); + int noiseEnd = pickSample; + int signalStart = pickSample; + int signalEnd = std::min(rec->sampleCount(), pickSample + postSamples); + + // Compute RMS in noise window + double noiseSum = 0.0; + int noiseSamples = 0; + for ( int i = noiseStart; i < noiseEnd; ++i ) { + double amp = data ? data[i] : 0.0; + noiseSum += amp * amp; + ++noiseSamples; + } + double noiseRMS = noiseSamples > 0 ? std::sqrt(noiseSum / noiseSamples) : 0.0; + + // Compute RMS in signal window + double signalSum = 0.0; + int signalSamples = 0; + for ( int i = signalStart; i < signalEnd; ++i ) { + double amp = data ? data[i] : 0.0; + signalSum += amp * amp; + ++signalSamples; + } + double signalRMS = signalSamples > 0 ? std::sqrt(signalSum / signalSamples) : 0.0; + + // Compute SNR + if ( noiseRMS < 1e-10 ) { + outSNR = -1.0; + outQuality = "unknown"; + } else { + outSNR = signalRMS / noiseRMS; + // Map SNR to quality + if ( outSNR > 10 ) outQuality = "excellent"; + else if ( outSNR > 5 ) outQuality = "good"; + else if ( outSNR > 2 ) outQuality = "fair"; + else outQuality = "poor"; + } + + return true; +} +// <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<< + + +// Helper function to compute SNR around a pick time +double computePickSNR(RecordWidget* widget, const Core::Time& pickTime, double preWindow = 5.0, double postWindow = 10.0) { + int slot = widget->currentRecords(); + if ( slot < 0 ) { + SEISCOMP_DEBUG("computePickSNR: invalid slot %d", slot); + return -1.0; + } + + // Use filtered records if filtering is enabled + RecordSequence* seq = widget->isFilteringEnabled() + ? widget->filteredRecords(slot) + : widget->records(slot); + + if ( !seq || seq->empty() ) { + SEISCOMP_DEBUG("computePickSNR: no records in slot %d", slot); + return -1.0; + } + + // Find the record containing pickTime + auto it = seq->lowerBound(pickTime); + if ( it == seq->end() ) { + SEISCOMP_DEBUG("computePickSNR: no record containing pick time"); + return -1.0; + } + + RecordCPtr rec = *it; + if ( !rec ) { + SEISCOMP_DEBUG("computePickSNR: null record"); + return -1.0; + } + + double fsamp = rec->samplingFrequency(); + if ( fsamp <= 0 ) { + SEISCOMP_DEBUG("computePickSNR: invalid sampling frequency %.2f", fsamp); + return -1.0; + } + + // Get data array - try double first, then float + const double* data = nullptr; + std::vector tempData; // For float conversion + + auto dArray = DoubleArray::ConstCast(rec->data()); + if ( dArray ) { + if ( dArray->size() > 0 ) { + data = dArray->typedData(); + } + } else { + auto fArray = FloatArray::ConstCast(rec->data()); + if ( fArray && fArray->size() > 0 ) { + // Convert float data to double for processing + const float* fdata = fArray->typedData(); + tempData.resize(fArray->size()); + for ( size_t i = 0; i < fArray->size(); ++i ) { + tempData[i] = static_cast(fdata[i]); + } + data = tempData.data(); + } + } + + if ( !data ) { + SEISCOMP_DEBUG("computePickSNR: null data pointer"); + return -1.0; + } + + int sampleCount = rec->sampleCount(); + Core::Time startTime = rec->startTime(); + + // Compute sample indices for windows + int preSamples = static_cast(preWindow * fsamp); + int postSamples = static_cast(postWindow * fsamp); + + // Find sample index for pick time + int pickSample = static_cast((pickTime - startTime).length() * fsamp); + + // Validate pick sample range + if ( pickSample < 0 || pickSample >= sampleCount ) { + SEISCOMP_DEBUG("computePickSNR: pickSample %d out of range [0, %d)", pickSample, sampleCount); + return -1.0; + } + + SEISCOMP_DEBUG("computePickSNR: pickTime=%s, startTime=%s, fsamp=%.2f, pickSample=%d, sampleCount=%d", + pickTime.toString("%H:%M:%S.%f").c_str(), + startTime.toString("%H:%M:%S.%f").c_str(), + fsamp, pickSample, sampleCount); + + // Compute RMS in pre-pick window (noise) + // Use adaptive window sizing - ensure at least 1 second of data + int minNoiseSamples = static_cast(fsamp); // At least 1 second + int noiseStart = std::max(0, pickSample - preSamples); + int noiseEnd = pickSample; + + // Ensure we have enough samples for meaningful statistics + if ( (noiseEnd - noiseStart) < minNoiseSamples ) { + // Try to extend the window + noiseStart = std::max(0, noiseEnd - minNoiseSamples); + } + + double noiseSum = 0.0; + int noiseSamples = 0; + + for ( int i = noiseStart; i < noiseEnd; ++i ) { + double amp = data[i]; + noiseSum += amp * amp; + ++noiseSamples; + } + + double noiseRMS = noiseSamples > 0 ? std::sqrt(noiseSum / noiseSamples) : 0.0; + + // Compute RMS in post-pick window (signal + noise) + int signalStart = pickSample; + int signalEnd = std::min(sampleCount, pickSample + postSamples); + + // Ensure minimum signal window + if ( (signalEnd - signalStart) < minNoiseSamples ) { + signalEnd = std::min(sampleCount, signalStart + minNoiseSamples); + } + + double signalSum = 0.0; + int signalSamples = 0; + + for ( int i = signalStart; i < signalEnd; ++i ) { + double amp = data[i]; + signalSum += amp * amp; + ++signalSamples; + } + + double signalRMS = signalSamples > 0 ? std::sqrt(signalSum / signalSamples) : 0.0; + + SEISCOMP_DEBUG("computePickSNR: noiseSamples=%d, noiseRMS=%.6f, signalSamples=%d, signalRMS=%.6f", + noiseSamples, noiseRMS, signalSamples, signalRMS); + + // Compute SNR + if ( noiseRMS < 1e-10 ) { + SEISCOMP_DEBUG("computePickSNR: noiseRMS too small, returning -1"); + return -1.0; + } + + double snr = signalRMS / noiseRMS; + SEISCOMP_DEBUG("computePickSNR: SNR=%.2f", snr); + return snr; +} + +// Helper function to get quality string from SNR +QString getPickQualityString(double snr) { + if ( snr < 0 ) return QObject::tr("unknown"); + if ( snr > 10 ) return QObject::tr("excellent"); + if ( snr > 5 ) return QObject::tr("good"); + if ( snr > 2 ) return QObject::tr("fair"); + return QObject::tr("poor"); +} + + class ZoomRecordWidget : public RecordWidget { public: ZoomRecordWidget() { @@ -2693,9 +2950,19 @@ void PickerView::init() { Mac::addFullscreen(this); #endif + // Install custom accessibility factory for RecordWidget and RecordViewItem + QAccessible::installFactory(pickerViewAccessibleFactory); + SC_D.ui.setupUi(this); SC_D.ui.actionShowTraceValuesInNmS->setChecked(SC_D.config.showDataInSensorUnit); + // Set accessible properties for central widget (used for announcements) + SC_D.ui.centralwidget->setAccessibleName(tr("Seismic Phase Picker")); + SC_D.ui.centralwidget->setAccessibleDescription(tr( + "Interactive tool for viewing seismic waveforms and picking P and S phase arrivals. " + "Use arrow keys to navigate, space to create picks." + )); + QFont f(font()); f.setBold(true); SC_D.ui.labelStationCode->setFont(f); @@ -2736,6 +3003,30 @@ void PickerView::init() { connect(SC_D.recordView, SIGNAL(currentItemChanged(RecordViewItem*,RecordViewItem*)), this, SLOT(itemSelected(RecordViewItem*,RecordViewItem*))); + // Announce station changes for screen reader accessibility + connect(SC_D.recordView, &RecordView::currentItemChanged, + this, [this](RecordViewItem* current, RecordViewItem* previous) { + if (current) { + QString netSta = current->label()->text(0); + QString info = tr("Selected station: %1").arg(netSta); + + // Add distance and azimuth if available + if (current->value(ITEM_DISTANCE_INDEX) >= 0) { + if (SCScheme.unit.distanceInKM) { + info += tr(", distance %1 km").arg(Math::Geo::deg2km(current->value(ITEM_DISTANCE_INDEX)), 0, 'f', SCScheme.precision.distance); + } else { + info += tr(", distance %1°").arg(current->value(ITEM_DISTANCE_INDEX), 0, 'f', 1); + } + } + if (current->value(ITEM_AZIMUTH_INDEX) >= 0) { + info += tr(", azimuth %1°").arg(current->value(ITEM_AZIMUTH_INDEX), 0, 'f', 1); + } + + statusBar()->showMessage(info, 3000); + announceToScreenReader(info); + } + }); + connect(SC_D.recordView, SIGNAL(fedRecord(RecordViewItem*,const Seiscomp::Record*)), this, SLOT(updateTraceInfo(RecordViewItem*,const Seiscomp::Record*))); @@ -2757,6 +3048,11 @@ void PickerView::init() { SC_D.recordView->setRowSpacing(2); SC_D.recordView->setHorizontalSpacing(6); SC_D.recordView->setFramesEnabled(false); + // Set accessible names for screen readers + SC_D.recordView->setAccessibleName(tr("Station list")); + SC_D.recordView->setAccessibleDescription(tr( + "List of seismic stations. Click to select a station. " + "Selected station waveform is shown in detail view above.")); //SC_D.recordView->setDefaultActions(); SC_D.connectionState = new ConnectionStateLabel(this); @@ -2798,6 +3094,12 @@ void PickerView::init() { statusBar()->addPermanentWidget(SC_D.searchLabel, 5); statusBar()->addPermanentWidget(SC_D.connectionState); + // Add mode indicator for accessibility + SC_D.modeLabel = new QLabel(tr("Mode: View")); + SC_D.modeLabel->setAccessibleName(tr("Current picking mode")); + SC_D.modeLabel->setToolTip(tr("Shows whether you are in view mode or picking P/S phases")); + statusBar()->addPermanentWidget(SC_D.modeLabel); + SC_D.currentRecord = new ZoomRecordWidget(); SC_D.currentRecord->showScaledValues(SC_D.ui.actionShowTraceValuesInNmS->isChecked()); SC_D.currentRecord->setClippingEnabled(SC_D.ui.actionClipComponentsToViewport->isChecked()); @@ -2808,6 +3110,11 @@ void PickerView::init() { SC_D.currentRecord->setDrawAxis(true); SC_D.currentRecord->setDrawSPS(true); SC_D.currentRecord->setAxisPosition(RecordWidget::Left); + // Set accessible names for screen readers + SC_D.currentRecord->setAccessibleName(tr("Waveform display")); + SC_D.currentRecord->setAccessibleDescription(tr( + "Seismic waveform view. Current phase for picking is shown in status bar. " + "Click on waveform to move cursor. Press Space to create pick at cursor.")); static_cast(SC_D.currentRecord)->setShowAmplitudes(SC_D.ui.actionShowAmplitudeValuesAtCursor->isChecked()); static_cast(SC_D.currentRecord)->spectrogramAmplitudesChanged = bind( &PickerView::specAmplitudesChanged, @@ -2973,6 +3280,10 @@ void PickerView::init() { addAction(SC_D.ui.actionSetPick); addAction(SC_D.ui.actionDeletePick); + addAction(SC_D.ui.actionToggleAudioSonification); + addAction(SC_D.ui.actionPlayTraceAudio); + addAction(SC_D.ui.actionStopAudioPlayback); + addAction(SC_D.ui.actionShowZComponent); addAction(SC_D.ui.actionShowNComponent); addAction(SC_D.ui.actionShowEComponent); @@ -2998,12 +3309,16 @@ void PickerView::init() { addAction(SC_D.ui.actionShowSpectrogram); + addAction(SC_D.ui.actionAnnouncePickDetails); + SC_D.lastFilterIndex = 0; SC_D.comboFilter = new QComboBox; //SC_D.comboFilter->setSizePolicy(QSizePolicy::Maximum, QSizePolicy::Preferred); SC_D.comboFilter->setDuplicatesEnabled(false); SC_D.comboFilter->addItem(NO_FILTER_STRING); + SC_D.comboFilter->setAccessibleName(tr("Filter selection")); + SC_D.comboFilter->setAccessibleDescription(tr("Select a frequency filter to apply to seismic data")); SC_D.comboFilter->setCurrentIndex(SC_D.lastFilterIndex); changeFilter(SC_D.comboFilter->currentIndex()); @@ -3017,6 +3332,8 @@ void PickerView::init() { SC_D.comboRotation->addItem(PickerView::Config::ERotationTypeNames::name(i)); } SC_D.comboRotation->setCurrentIndex(SC_D.currentRotationMode); + SC_D.comboRotation->setAccessibleName(tr("Component rotation")); + SC_D.comboRotation->setAccessibleDescription(tr("Select rotation type for three-component seismic data (123, ZNE, ZRT, LQT, or ZH)")); changeRotation(SC_D.comboRotation->currentIndex()); SC_D.ui.toolBarFilter->insertWidget(SC_D.ui.actionToggleFilter, SC_D.comboRotation); @@ -3027,6 +3344,8 @@ void PickerView::init() { SC_D.comboUnit->addItem(PickerView::Config::EUnitTypeNames::name(i)); } SC_D.comboUnit->setCurrentIndex(SC_D.currentUnitMode); + SC_D.comboUnit->setAccessibleName(tr("Unit type")); + SC_D.comboUnit->setAccessibleDescription(tr("Select display unit: raw sensor counts, acceleration, velocity, or displacement")); SC_D.ui.toolBarFilter->insertWidget(SC_D.ui.actionToggleFilter, SC_D.comboUnit); @@ -3053,6 +3372,8 @@ void PickerView::init() { SC_D.ui.toolBarTTT->addWidget(SC_D.comboTTT); SC_D.comboTTT->setToolTip(tr("Select one of the supported travel time table backends.")); + SC_D.comboTTT->setAccessibleName(tr("Travel time table backend")); + SC_D.comboTTT->setAccessibleDescription(tr("Select the travel time model for calculating theoretical arrival times")); TravelTimeTableInterfaceFactory::ServiceNames *ttServices = TravelTimeTableInterfaceFactory::Services(); if ( ttServices ) { TravelTimeTableInterfaceFactory::ServiceNames::iterator it; @@ -3072,6 +3393,8 @@ void PickerView::init() { connect(SC_D.comboTTT, SIGNAL(currentIndexChanged(int)), this, SLOT(ttInterfaceChanged(int))); SC_D.comboTTTables = new QComboBox; SC_D.comboTTTables->setToolTip(tr("Select one of the supported tables for the current travel time table backend.")); + SC_D.comboTTTables->setAccessibleName(tr("Travel time table")); + SC_D.comboTTTables->setAccessibleDescription(tr("Select the specific travel time table for the chosen backend")); SC_D.ui.toolBarTTT->addWidget(SC_D.comboTTTables); ttInterfaceChanged(SC_D.comboTTT->currentIndex()); connect(SC_D.comboTTTables, SIGNAL(currentIndexChanged(int)), this, SLOT(ttTableChanged(int))); @@ -3121,6 +3444,8 @@ void PickerView::init() { SC_D.spinDistance = new QDoubleSpinBox; SC_D.spinDistance->setValue(15); + SC_D.spinDistance->setAccessibleName(tr("Station distance range")); + SC_D.spinDistance->setAccessibleDescription(tr("Set the maximum distance from earthquake for adding nearby stations")); if ( SCScheme.unit.distanceInKM ) { SC_D.spinDistance->setRange(0, 25000); @@ -3244,6 +3569,25 @@ void PickerView::init() { this, SLOT(confirmPick())); connect(SC_D.ui.actionDeletePick, SIGNAL(triggered(bool)), this, SLOT(deletePick())); + connect(SC_D.ui.actionAnnouncePickDetails, SIGNAL(triggered(bool)), + this, SLOT(announceCurrentPickDetails())); + + SC_D.audioSonification = new WaveformAudio(this); + connect(SC_D.ui.actionToggleAudioSonification, SIGNAL(triggered(bool)), + this, SLOT(toggleAudioSonification())); + connect(SC_D.ui.actionPlayTraceAudio, SIGNAL(triggered(bool)), + this, SLOT(playCurrentTraceAudio())); + connect(SC_D.ui.actionStopAudioPlayback, SIGNAL(triggered(bool)), + this, SLOT(stopAudioPlayback())); + SC_D.ui.toolBarSonification->addAction(SC_D.ui.actionToggleAudioSonification); + SC_D.ui.toolBarSonification->addAction(SC_D.ui.actionPlayTraceAudio); + SC_D.ui.toolBarSonification->addAction(SC_D.ui.actionStopAudioPlayback); + + SC_D.audioDebounceTimer = new QTimer(this); + SC_D.audioDebounceTimer->setSingleShot(true); + SC_D.audioDebounceTimer->setInterval(300); + connect(SC_D.audioDebounceTimer, &QTimer::timeout, + this, &PickerView::playAudioAtCursor); connect(SC_D.ui.actionRelocate, SIGNAL(triggered(bool)), this, SLOT(relocate())); @@ -3982,6 +4326,53 @@ void PickerView::changeEvent(QEvent *e) { +// >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> +void PickerView::keyPressEvent(QKeyEvent *event) { + // Handle Applications/Menu key for accessible context menus + if ( event->key() == Qt::Key_Menu || event->key() == Qt::Key_Context1 ) { + // Determine which context menu to show based on current focus/selection + RecordViewItem *item = SC_D.recordView->currentItem(); + if ( item && item->widget() ) { + RecordWidget *widget = item->widget(); + + // Check if there's a current marker (pick/arrival) at cursor + PickerMarker *marker = static_cast(widget->currentMarker()); + if ( !marker ) { + marker = static_cast(widget->marker(widget->cursorText())); + } + + if ( marker && (marker->isPick() || marker->isArrival()) ) { + // Show record context menu for pick/arrival + // Position menu at center of widget for keyboard access + // Ensure zoom widget knows which marker we're showing + QPoint centerPos = SC_D.currentRecord->rect().center(); + openRecordContextMenu(centerPos); + event->accept(); + return; + } else if ( !widget->cursorText().isEmpty() ) { + // Has cursor but no marker - still show record context menu + QPoint centerPos = SC_D.currentRecord->rect().center(); + openRecordContextMenu(centerPos); + event->accept(); + return; + } + } + + // No specific record context - don't show general context menu + // as it requires a valid origin and station context + announceToScreenReader(tr("No context menu available for current selection")); + event->accept(); + return; + } + + // Pass other key events to parent class + QMainWindow::keyPressEvent(event); +} +// <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<< + + + + // >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> void PickerView::onSelectedTime(Seiscomp::Core::Time time) { //updatePhaseMarker(SC_D.currentRecord, time); @@ -4114,10 +4505,16 @@ void PickerView::updatePhaseMarker(Seiscomp::Gui::RecordWidget* widget, } widget->setCurrentMarker(marker); + + // Announce pick creation for screen readers with full details + announcePickDetails(widget, time, marker->text(), false); } else { declareArrival(reusedMarker, widget->cursorText(), false); widget->setCurrentMarker(reusedMarker); + + // Announce pick update for screen readers with full details + announcePickDetails(widget, time, widget->cursorText(), true); } widget->update(); @@ -5802,6 +6199,11 @@ bool PickerView::fillRawPicks() { pickAdded = result || pickAdded; } + // Announce summary of loaded picks for screen readers + if ( pickAdded && SC_D.picksInTime.size() > 0 ) { + announceToScreenReader(tr("Loaded %1 picks in time window").arg(SC_D.picksInTime.size())); + } + return pickAdded; } // <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<< @@ -5877,6 +6279,32 @@ bool PickerView::addRawPick(Seiscomp::DataModel::Pick *pick) { marker->setVisible(CFG_LOAD_PICKS); marker->update(); + // Announce existing pick for screen readers when loading + // Only announce if we're in the current time window and picks are being loaded + if ( CFG_LOAD_PICKS && widget == SC_D.currentRecord ) { + QString phaseName = marker->text(); + QString timeStr = QString::fromStdString(pick->time().value().toString("%H:%M:%S.%f")); + QString stationCode = item->label()->text(0); + + // Compute SNR if data is available + double snr = computePickSNR(widget, pick->time().value()); + QString quality = getPickQualityString(snr); + + if ( snr > 0 ) { + // First announce basic pick info + announceToScreenReader(tr("Existing %1 pick at %2, %3").arg(phaseName).arg(stationCode).arg(timeStr)); + // Then announce quality with a slight delay + QMetaObject::invokeMethod(this, "delayedQualityAnnouncement", + Qt::QueuedConnection, + Q_ARG(QString, tr("Quality %1, SNR %2").arg(quality).arg(snr, 0, 'f', 1))); + } else { + announceToScreenReader(tr("Existing %1 pick at %2, %3") + .arg(phaseName) + .arg(stationCode) + .arg(timeStr)); + } + } + return true; } // <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<< @@ -6010,21 +6438,58 @@ void PickerView::setPickPolarity() { old->parent()->setCurrentMarker(m); } + QString polarityName; if ( sender() == SC_D.ui.actionSetPolarityPositive ) { m->setPolarity(PickPolarity(POSITIVE)); + polarityName = tr("positive (up)"); } else if ( sender() == SC_D.ui.actionSetPolarityNegative ) { m->setPolarity(PickPolarity(NEGATIVE)); + polarityName = tr("negative (down)"); } else if ( sender() == SC_D.ui.actionSetPolarityUndecidable ) { m->setPolarity(PickPolarity(UNDECIDABLE)); + polarityName = tr("undecidable"); } else if ( sender() == SC_D.ui.actionSetPolarityUnset ) { m->setPolarity(Core::None); + polarityName = tr("unset"); + } + else { + QAction *act = static_cast(sender()); + if ( act ) { + int val = act->data().toInt(); + switch ( val ) { + case DataModel::POSITIVE: + m->setPolarity(PickPolarity(DataModel::POSITIVE)); + polarityName = tr("positive (up)"); + break; + case DataModel::NEGATIVE: + m->setPolarity(PickPolarity(DataModel::NEGATIVE)); + polarityName = tr("negative (down)"); + break; + case DataModel::UNDECIDABLE: + m->setPolarity(PickPolarity(DataModel::UNDECIDABLE)); + polarityName = tr("undecidable"); + break; + default: + m->setPolarity(Core::None); + polarityName = tr("unset"); + break; + } + } + else { + return; + } } SC_D.currentRecord->update(); SC_D.recordView->currentItem()->widget()->update(); + + // Announce polarity change for screen readers + QString phaseName = m->text(); + QString timeStr = QString::fromStdString(m->correctedTime().toString("%H:%M:%S.%f")); + announceToScreenReader(tr("Set %1 phase polarity to %2 at %3").arg(phaseName).arg(polarityName).arg(timeStr)); } // <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<< @@ -6050,21 +6515,58 @@ void PickerView::setPickOnset() { old->parent()->setCurrentMarker(m); } + QString onsetName; if ( sender() == SC_D.ui.actionSetPickOnsetEmergent ) { m->setPickOnset(PickOnset(EMERGENT)); + onsetName = tr("emergent"); } else if ( sender() == SC_D.ui.actionSetPickOnsetImpulsive ) { m->setPickOnset(PickOnset(IMPULSIVE)); + onsetName = tr("impulsive"); } else if ( sender() == SC_D.ui.actionSetPickOnsetQuestionable ) { m->setPickOnset(PickOnset(QUESTIONABLE)); + onsetName = tr("questionable"); } else if ( sender() == SC_D.ui.actionSetPickOnsetUnset ) { m->setPickOnset(Core::None); + onsetName = tr("unset"); + } + else { + QAction *act = static_cast(sender()); + if ( act ) { + int val = act->data().toInt(); + switch ( val ) { + case DataModel::EMERGENT: + m->setPickOnset(PickOnset(DataModel::EMERGENT)); + onsetName = tr("emergent"); + break; + case DataModel::IMPULSIVE: + m->setPickOnset(PickOnset(DataModel::IMPULSIVE)); + onsetName = tr("impulsive"); + break; + case DataModel::QUESTIONABLE: + m->setPickOnset(PickOnset(DataModel::QUESTIONABLE)); + onsetName = tr("questionable"); + break; + default: + m->setPickOnset(Core::None); + onsetName = tr("unset"); + break; + } + } + else { + return; + } } SC_D.currentRecord->update(); SC_D.recordView->currentItem()->widget()->update(); + + // Announce onset change for screen readers + QString phaseName = m->text(); + QString timeStr = QString::fromStdString(m->correctedTime().toString("%H:%M:%S.%f")); + announceToScreenReader(tr("Set %1 phase onset to %2 at %3").arg(phaseName).arg(onsetName).arg(timeStr)); } // <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<< @@ -6126,10 +6628,17 @@ void PickerView::setPickUncertainty() { old->parent()->setCurrentMarker(m); } - if ( idx == -1 ) + QString uncertaintyText; + if ( idx == -1 ) { m->setUncertainty(-1,-1); - else - m->setUncertainty(SC_D.uncertainties[idx].first, SC_D.uncertainties[idx].second); + uncertaintyText = tr("unset"); + } + else { + double lower = SC_D.uncertainties[idx].first; + double upper = SC_D.uncertainties[idx].second; + m->setUncertainty(lower, upper); + uncertaintyText = tr("-%1/+%2 seconds").arg(lower).arg(upper); + } updateUncertaintyHandles(m); @@ -6140,6 +6649,12 @@ void PickerView::setPickUncertainty() { SC_D.currentRecord->update(); SC_D.recordView->currentItem()->widget()->update(); + + // Announce uncertainty change for screen readers + QString phaseName = m->text(); + QString timeStr = QString::fromStdString(m->correctedTime().toString("%H:%M:%S.%f")); + announceToScreenReader(tr("Set %1 phase uncertainty to %2 at %3").arg(phaseName).arg(uncertaintyText).arg(timeStr)); + return; } } @@ -6158,6 +6673,9 @@ void PickerView::openContextMenu(const QPoint &p) { return; } + // Announce menu opening for screen readers + announceToScreenReader(tr("Station context menu opened. Use arrow keys to navigate options.")); + QMenu menu(this); int entries = 0; @@ -6283,10 +6801,34 @@ void PickerView::openContextMenu(const QPoint &p) { // >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> void PickerView::openRecordContextMenu(const QPoint &p) { - SC_D.currentRecord->setCurrentMarker(SC_D.currentRecord->hoveredMarker()); + // Safety check - ensure currentRecord is valid + if ( !SC_D.currentRecord ) { + SEISCOMP_WARNING("openRecordContextMenu: SC_D.currentRecord is null"); + return; + } + + // When triggered by mouse, use the hovered marker + // When triggered by keyboard (no hover), find marker by time at cursor + if ( SC_D.currentRecord->hoveredMarker() ) { + SC_D.currentRecord->setCurrentMarker(SC_D.currentRecord->hoveredMarker()); + } + else { + PickerMarker *found = nullptr; + if ( !SC_D.currentRecord->cursorText().isEmpty() ) { + found = static_cast( + SC_D.currentRecord->marker(SC_D.currentRecord->cursorText()) + ); + } + if ( !found ) { + found = static_cast( + SC_D.currentRecord->nearestMarker(SC_D.currentRecord->cursorPos(), 30) + ); + } + SC_D.currentRecord->setCurrentMarker(found); + } PickerMarker *m = static_cast(SC_D.currentRecord->currentMarker()); - QMenu menu; + QMenu menu(this); QAction *defineUncertainties = nullptr; QAction *dropDirectivity = nullptr; @@ -6307,8 +6849,23 @@ void PickerView::openRecordContextMenu(const QPoint &p) { if ( !markerMode && !SC_D.currentRecord->cursorText().isEmpty() ) { return; } + + // If neither marker mode nor cursor text, nothing to show + if ( !markerMode && SC_D.currentRecord->cursorText().isEmpty() ) { + SEISCOMP_WARNING("openRecordContextMenu: no marker and no cursor text"); + return; + } - if ( markerMode ) { + // Announce menu opening for screen readers + if ( markerMode && m ) { + QString phaseName = m->text(); + QString menuType = m->isArrival() ? tr("arrival") : tr("pick"); + announceToScreenReader(tr("%1 %2 context menu opened. Use arrow keys to navigate options.").arg(menuType).arg(phaseName)); + } else { + announceToScreenReader(tr("Pick context menu opened. Use arrow keys to navigate options.")); + } + + if ( markerMode && m ) { // Save uncertainties to reset them again if changed // during preview SC_D.tmpLowerUncertainty = m->lowerUncertainty(); @@ -6322,10 +6879,35 @@ void PickerView::openRecordContextMenu(const QPoint &p) { QMenu *menuOnset = menu.addMenu(tr("Onset")); QMenu *menuUncertainty = menu.addMenu(tr("Uncertainty")); + QAction *polPositive = menuPolarity->addAction(tr("positive")); + polPositive->setData(static_cast(DataModel::POSITIVE)); + connect(polPositive, SIGNAL(triggered(bool)), this, SLOT(setPickPolarity())); + QAction *polNegative = menuPolarity->addAction(tr("negative")); + polNegative->setData(static_cast(DataModel::NEGATIVE)); + connect(polNegative, SIGNAL(triggered(bool)), this, SLOT(setPickPolarity())); + QAction *polUndecidable = menuPolarity->addAction(tr("undecidable")); + polUndecidable->setData(static_cast(DataModel::UNDECIDABLE)); + connect(polUndecidable, SIGNAL(triggered(bool)), this, SLOT(setPickPolarity())); + QAction *polUnset = menuPolarity->addAction(tr("unset")); + polUnset->setData(-1); + connect(polUnset, SIGNAL(triggered(bool)), this, SLOT(setPickPolarity())); + + QAction *onsEmergent = menuOnset->addAction(tr("emergent")); + onsEmergent->setData(static_cast(DataModel::EMERGENT)); + connect(onsEmergent, SIGNAL(triggered(bool)), this, SLOT(setPickOnset())); + QAction *onsImpulsive = menuOnset->addAction(tr("impulsive")); + onsImpulsive->setData(static_cast(DataModel::IMPULSIVE)); + connect(onsImpulsive, SIGNAL(triggered(bool)), this, SLOT(setPickOnset())); + QAction *onsQuestionable = menuOnset->addAction(tr("questionable")); + onsQuestionable->setData(static_cast(DataModel::QUESTIONABLE)); + connect(onsQuestionable, SIGNAL(triggered(bool)), this, SLOT(setPickOnset())); + QAction *onsUnset = menuOnset->addAction(tr("unset")); + onsUnset->setData(-1); + connect(onsUnset, SIGNAL(triggered(bool)), this, SLOT(setPickOnset())); + if ( SC_D.actionsUncertainty ) { connect(menuUncertainty, SIGNAL(hovered(QAction*)), this, SLOT(previewUncertainty(QAction*))); - foreach ( QAction *action, SC_D.actionsUncertainty->actions() ) { menuUncertainty->addAction(action); } @@ -6333,16 +6915,6 @@ void PickerView::openRecordContextMenu(const QPoint &p) { } defineUncertainties = menuUncertainty->addAction(tr("Define...")); - - menuPolarity->addAction(SC_D.ui.actionSetPolarityPositive); - menuPolarity->addAction(SC_D.ui.actionSetPolarityNegative); - menuPolarity->addAction(SC_D.ui.actionSetPolarityUndecidable); - menuPolarity->addAction(SC_D.ui.actionSetPolarityUnset); - - menuOnset->addAction(SC_D.ui.actionSetPickOnsetEmergent); - menuOnset->addAction(SC_D.ui.actionSetPickOnsetImpulsive); - menuOnset->addAction(SC_D.ui.actionSetPickOnsetQuestionable); - menuOnset->addAction(SC_D.ui.actionSetPickOnsetUnset); } bool needSeparator = !menu.isEmpty(); @@ -6387,7 +6959,7 @@ void PickerView::openRecordContextMenu(const QPoint &p) { QAction *res = menu.exec(SC_D.currentRecord->mapToGlobal(p)); if ( !res ) { - if ( markerMode ) { + if ( markerMode && m ) { m->setUncertainty(SC_D.tmpLowerUncertainty, SC_D.tmpUpperUncertainty); m->update(); SC_D.currentRecord->update(); @@ -6586,6 +7158,90 @@ void PickerView::openRecordContextMenu(const QPoint &p) { // >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> void PickerView::currentMarkerChanged(Seiscomp::Gui::RecordMarker *m) { updateUncertaintyHandles(m); + + // Note: Screen reader announcements for marker selection are now handled + // by the specific navigation functions (gotoNextMarker, gotoPreviousMarker, + // setCursorPos) to avoid duplicate announcements and to include amplitude/SNR. + // This function is called for all marker changes, including programmatic ones + // where we don't want to announce. + + /* + // Announce marker selection for screen readers + if ( m ) { + PickerMarker *pickerMarker = static_cast(m); + QString phaseName = pickerMarker->text(); + QString timeStr = QString::fromStdString(m->correctedTime().toString("%Y-%m-%d %H:%M:%S.%f")); + QString markerType; + + if ( pickerMarker->isArrival() ) { + if ( pickerMarker->pick() ) { + markerType = tr("pick"); + } else { + markerType = tr("arrival"); + } + } else if ( pickerMarker->isPick() ) { + markerType = tr("manual pick"); + } else if ( pickerMarker->type() == PickerMarker::Theoretical ) { + markerType = tr("theoretical arrival"); + } else { + markerType = tr("marker"); + } + + // Build detailed announcement with pick attributes if available + QString announcement = tr("Selected %1 %2 at %3").arg(phaseName).arg(markerType).arg(timeStr); + + // Add polarity if set + if ( pickerMarker->polarity() ) { + QString polStr; + switch ( *pickerMarker->polarity() ) { + case DataModel::POSITIVE: + polStr = tr("positive polarity"); + break; + case DataModel::NEGATIVE: + polStr = tr("negative polarity"); + break; + case DataModel::UNDECIDABLE: + polStr = tr("undecidable polarity"); + break; + default: + polStr = tr("unknown polarity"); + break; + } + announcement += tr(", %1").arg(polStr); + } + + // Add onset if set + if ( pickerMarker->onset() ) { + QString onsetStr; + switch ( *pickerMarker->onset() ) { + case DataModel::EMERGENT: + onsetStr = tr("emergent onset"); + break; + case DataModel::IMPULSIVE: + onsetStr = tr("impulsive onset"); + break; + case DataModel::QUESTIONABLE: + onsetStr = tr("questionable onset"); + break; + default: + onsetStr = tr("unknown onset"); + break; + } + announcement += tr(", %1").arg(onsetStr); + } + + // Add uncertainty if set + if ( pickerMarker->hasUncertainty() ) { + double lower = pickerMarker->lowerUncertainty(); + double upper = pickerMarker->upperUncertainty(); + if ( lower >= 0 && upper >= 0 ) { + announcement += tr(", uncertainty -%1/+%2 seconds").arg(lower).arg(upper); + } + } + + announceToScreenReader(announcement); + } + */ } // <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<< @@ -6781,6 +7437,10 @@ RecordViewItem* PickerView::addRawStream(const DataModel::SensorLocation *loc, connect(item->label(), SIGNAL(customContextMenuRequested(QPoint)), this, SLOT(openContextMenu(QPoint))); + // Set accessible name for screen readers + item->label()->setAccessibleName(tr("Station %1").arg(text.c_str())); + item->label()->setAccessibleDescription(tr("Station list item for %1. Double-click to enable/disable. Right-click for menu.").arg(text.c_str())); + if ( SC_D.currentRecord ) item->widget()->setCursorText(SC_D.currentRecord->cursorText()); @@ -7144,17 +7804,16 @@ void PickerView::updateSubCursor(RecordWidget* w, int s) { SC_D.recordView->currentItem()->widget()->setCursorPos(w->cursorPos()); SC_D.recordView->currentItem()->widget()->blockSignals(false); -<<<<<<< HEAD - if ( true ) { - announceAmplitude(); -======= announceAmplitude(); + + if ( SC_D.audioSonification->isEnabled() ) { + SC_D.audioDebounceTimer->start(); + } } // <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<< - // >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> namespace { @@ -7187,7 +7846,6 @@ void PickerView::applyThemeColors() { SC_D.btnApply->setPalette(pal); SC_D.btnApply->setIcon(SC_D.btnApply->icon()); setIconColor(SC_D.btnApply, colorTheme->foregroundConfirm); ->>>>>>> e0ce74329ae8520ae62312544942fb745c885087 } } // <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<< @@ -7199,6 +7857,8 @@ void PickerView::applyThemeColors() { void PickerView::announceAmplitude() { bool gotAmplitude = false; double amplitude; + double snr = -1.0; + QString quality = "unknown"; auto seq = SC_D.currentRecord->isFilteringEnabled() @@ -7221,6 +7881,15 @@ void PickerView::announceAmplitude() { amplitude *= *SC_D.currentRecord->recordScale(SC_D.currentSlot); } gotAmplitude = true; + + // Compute SNR for better context + snr = computePickSNR(SC_D.currentRecord, SC_D.currentRecord->cursorPos()); + if ( snr > 0 ) { + if ( snr > 10 ) quality = "excellent"; + else if ( snr > 5 ) quality = "good"; + else if ( snr > 2 ) quality = "fair"; + else quality = "poor"; + } } } else { @@ -7232,6 +7901,15 @@ void PickerView::announceAmplitude() { amplitude *= *SC_D.currentRecord->recordScale(SC_D.currentSlot); } gotAmplitude = true; + + // Compute SNR for better context + snr = computePickSNR(SC_D.currentRecord, SC_D.currentRecord->cursorPos()); + if ( snr > 0 ) { + if ( snr > 10 ) quality = "excellent"; + else if ( snr > 5 ) quality = "good"; + else if ( snr > 2 ) quality = "fair"; + else quality = "poor"; + } } } } @@ -7240,40 +7918,109 @@ void PickerView::announceAmplitude() { } if ( !gotAmplitude ) { -<<<<<<< HEAD - // TODO: Stop playback - qDebug() << "-1"; -======= SC_D.ui.progressAmpLevel->setEnabled(false); SC_D.ui.progressAmpLevel->setValue(0); ->>>>>>> e0ce74329ae8520ae62312544942fb745c885087 } else { auto range = SC_D.currentRecord->amplitudeDataRange(SC_D.currentSlot); auto width = range.second - range.first; auto level = width != 0.0 ? (amplitude - range.first) / width : 0.5; -<<<<<<< HEAD - // Level is from 0 to 1 where 0 is the lower end of the amplitude range - // and 1 is the upper end. 0.5 is the center of the view but not - // necessarily the data offset. - - const double lowerFrequency = 440.0; - const double upperFrequency = 880.0; - double frequency = (upperFrequency - lowerFrequency) * level + lowerFrequency; - double percent = 100.0 * level; - QString format = tr("%1 %").arg(QString::number(percent, 'f', 1)); - - // TODO: Start playback of frequency - qDebug() << frequency; - SC_D.ui.labelCurrentAmp->setAccessibleName(QString::number(frequency)); - SC_D.ui.labelCurrentAmp->setTextInteractionFlags(Qt::TextBrowserInteraction); - SC_D.ui.ampProgress->setRange(0, 100); - SC_D.ui.ampProgress->setFormat(format); - SC_D.ui.ampProgress->setValue(static_cast(percent)); -======= SC_D.ui.progressAmpLevel->setEnabled(true); SC_D.ui.progressAmpLevel->setValue(static_cast(100.0 * level)); ->>>>>>> e0ce74329ae8520ae62312544942fb745c885087 + + // Announce amplitude with SNR for screen reader accessibility + QString timeStr = QString::fromStdString(SC_D.currentRecord->cursorPos().toString("%H:%M:%S.%f")); + if ( snr > 0 ) { + announceToScreenReader(tr("Amplitude %1, SNR %2, quality %3 at %4") + .arg(amplitude, 0, 'e', 2) + .arg(snr, 0, 'f', 1) + .arg(quality) + .arg(timeStr)); + } else { + announceToScreenReader(tr("Amplitude %1 at %2") + .arg(amplitude, 0, 'e', 2) + .arg(timeStr)); + } + } +} +// <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<< + + + + +// >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> +void PickerView::announcePickDetails(RecordWidget* widget, const Core::Time& pickTime, + const QString& phaseName, bool isUpdate) { + if ( !widget ) return; + + QString timeStr = QString::fromStdString(pickTime.toString("%H:%M:%S.%f")); + QString action = isUpdate ? tr("Updated") : tr("Created"); + + // Compute amplitude and SNR for the pick + double amplitude = 0.0, snr = -1.0; + QString quality = "unknown"; + + bool success = computePickAmplitudeAndSNR(widget, pickTime, amplitude, snr, quality); + + if ( success ) { + // Build comprehensive announcement with all available information + QString announcement; + + if ( snr > 0 ) { + // Full detail with SNR + announcement = tr("%1 %2 pick at %3, amplitude %4, SNR %5, quality %6") + .arg(action) + .arg(phaseName) + .arg(timeStr) + .arg(amplitude, 0, 'e', 2) + .arg(snr, 0, 'f', 1) + .arg(quality); + } else { + // Basic announcement without SNR + announcement = tr("%1 %2 pick at %3, amplitude %4") + .arg(action) + .arg(phaseName) + .arg(timeStr) + .arg(amplitude, 0, 'e', 2); + } + + // Add station information if available + RecordViewItem* item = SC_D.recordView->currentItem(); + if ( item ) { + QString stationCode = item->label()->text(0); + QString channelCode = item->label()->text(1); + + if ( !stationCode.isEmpty() ) { + announcement += tr(", station %1").arg(stationCode); + } + if ( !channelCode.isEmpty() ) { + announcement += tr(", channel %1").arg(channelCode); + } + + // Add distance if available + QString distance = item->label()->text(ITEM_DISTANCE_INDEX); + if ( !distance.isEmpty() && distance != "-" ) { + announcement += tr(", distance %1").arg(distance); + } + } + + announceToScreenReader(announcement); + } else { + // Fallback announcement with minimal information + QString announcement = tr("%1 %2 pick at %3") + .arg(action) + .arg(phaseName) + .arg(timeStr); + + RecordViewItem* item = SC_D.recordView->currentItem(); + if ( item ) { + QString stationCode = item->label()->text(0); + if ( !stationCode.isEmpty() ) { + announcement += tr(" at station %1").arg(stationCode); + } + } + + announceToScreenReader(announcement); } } // <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<< @@ -7396,6 +8143,77 @@ void PickerView::setCursorPos(const Seiscomp::Core::Time& t, bool always) { return; } + // Announce cursor time change for screen readers when in picking mode + if ( !SC_D.currentRecord->cursorText().isEmpty() ) { + // Throttle announcements to avoid spam during rapid scrolling + // but allow reasonable feedback for arrow key navigation + static Core::Time lastAnnouncementTime; + static int announcementCount = 0; + + double timeSinceLastAnnouncement = (t - lastAnnouncementTime).length(); + + // For arrow key scrolling, announce every 0.3 seconds or on first movement + // This provides good feedback without overwhelming the user + bool shouldAnnounce = (timeSinceLastAnnouncement > 0.3) || (announcementCount == 0); + + if ( shouldAnnounce ) { + QString timeStr = QString::fromStdString(t.toString("%H:%M:%S.%f")); + QString phaseMode = SC_D.currentRecord->cursorText(); + + // Check if there's a pick at this time on the current station trace + // Note: We check the station list widget, not the zoom widget + PickerMarker* marker = nullptr; + if ( SC_D.recordView->currentItem() ) { + RecordWidget* stationWidget = SC_D.recordView->currentItem()->widget(); + RecordMarker* markerBase = stationWidget->marker(phaseMode, true); + if ( markerBase ) { + marker = static_cast(markerBase); + } + } + + if ( marker ) { + // Announce when cursor is on a pick with amplitude and SNR + QString timeStr = QString::fromStdString(t.toString("%H:%M:%S.%f")); + QString phaseMode = SC_D.currentRecord->cursorText(); + + // Try to compute amplitude and SNR from current record + double amplitude = 0.0, snr = -1.0; + QString quality = "unknown"; + if ( computePickAmplitudeAndSNR(SC_D.currentRecord, marker->time(), amplitude, snr, quality) ) { + if ( snr > 0 ) { + announceToScreenReader(tr("%1 pick at %2, amplitude %3, quality %4, SNR %5") + .arg(phaseMode) + .arg(timeStr) + .arg(amplitude, 0, 'e', 2) + .arg(quality) + .arg(snr, 0, 'f', 1)); + } else { + announceToScreenReader(tr("%1 pick at %2, amplitude %3") + .arg(phaseMode) + .arg(timeStr) + .arg(amplitude, 0, 'e', 2)); + } + } else { + announceToScreenReader(tr("%1 pick at %2").arg(phaseMode).arg(timeStr)); + } + } else { + // Announce cursor position periodically during navigation + // Announce every 3rd movement for better feedback + if ( announcementCount % 3 == 0 ) { + announceToScreenReader(tr("%1 mode, %2").arg(phaseMode).arg(timeStr)); + } + } + + lastAnnouncementTime = t; + ++announcementCount; + + // Reset counter after 30 announcements to allow fresh announcements + if ( announcementCount > 30 ) { + announcementCount = 0; + } + } + } + double offset = 0; if ( SC_D.centerSelection ) { @@ -7625,12 +8443,15 @@ void PickerView::move(double offset) { SC_D.recordView->move(offset); setTimeRange(tmin, tmax); + + if ( SC_D.audioSonification->isEnabled() ) { + SC_D.audioDebounceTimer->start(); + } } // <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<< - // >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> void PickerView::itemSelected(RecordViewItem* item, RecordViewItem* lastItem) { double smin = 0; @@ -7791,6 +8612,65 @@ void PickerView::itemSelected(RecordViewItem* item, RecordViewItem* lastItem) { cha.c_str()) ); + // Announce station change to screen readers + QString distanceText = SC_D.ui.labelDistance->text(); + QString azimuthText = SC_D.ui.labelAzimuth->text(); + announceToScreenReader(tr("Station %1, channel %2, distance %3, azimuth %4") + .arg(streamID.stationCode().c_str()) + .arg(cha.c_str()) + .arg(distanceText) + .arg(azimuthText)); + + // Announce existing picks on this station for screen readers + int pickCount = 0; + QStringList pickPhases; + for ( int m = 0; m < item->widget()->markerCount(); ++m ) { + PickerMarker* marker = static_cast(item->widget()->marker(m)); + if ( marker->isPick() || marker->isArrival() ) { + ++pickCount; + if ( !pickPhases.contains(marker->text()) ) { + pickPhases.append(marker->text()); + } + } + } + if ( pickCount > 0 ) { + // Concise format: "2 picks: P, S. Latest P at 10:30:45.2, quality good, SNR 5.3" + QString pickSummary = tr("%1 picks: ").arg(pickCount); + pickSummary += pickPhases.join(", "); + + // Get the most recent pick time for announcement + Core::Time latestPickTime; + PickerMarker* latestMarker = nullptr; + for ( int m = 0; m < item->widget()->markerCount(); ++m ) { + PickerMarker* marker = static_cast(item->widget()->marker(m)); + if ( (marker->isPick() || marker->isArrival()) && + (latestMarker == nullptr || marker->time() > latestMarker->time()) ) { + latestMarker = marker; + } + } + if ( latestMarker ) { + // Compute SNR for the latest pick + double snr = computePickSNR(item->widget(), latestMarker->time()); + QString quality = getPickQualityString(snr); + + if ( snr > 0 ) { + // First announce basic pick summary + announceToScreenReader(tr("%1. Latest %2 at %3").arg(pickSummary).arg(latestMarker->text()).arg(QString::fromStdString(latestMarker->time().toString("%H:%M:%S.%f")))); + // Then announce quality with a slight delay + QMetaObject::invokeMethod(this, "delayedQualityAnnouncement", + Qt::QueuedConnection, + Q_ARG(QString, tr("Quality %1, SNR %2").arg(quality).arg(snr, 0, 'f', 1))); + } else { + announceToScreenReader(tr("%1. Latest %2 at %3") + .arg(pickSummary) + .arg(latestMarker->text()) + .arg(QString::fromStdString(latestMarker->time().toString("%H:%M:%S.%f")))); + } + } else { + announceToScreenReader(pickSummary); + } + } + PickerRecordLabel *label = static_cast(item->label()); static_cast(SC_D.currentRecord)->setTraces(label->data.traces); @@ -8257,8 +9137,16 @@ void PickerView::alignOnSArrivals() { // >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> -void PickerView::pickP(bool) { +void PickerView::pickP(bool checked) { setCursorText("P"); + // Update mode indicator for accessibility + if (checked) { + SC_D.modeLabel->setText(tr("Mode: Pick P phase")); + announceToScreenReader(tr("P phase picking mode enabled. Click on waveform or press Space to set pick.")); + } else { + SC_D.modeLabel->setText(tr("Mode: View")); + announceToScreenReader(tr("P phase picking mode disabled. Now in view mode.")); + } } // <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<< @@ -8276,6 +9164,10 @@ void PickerView::pickNone(bool) { if ( SC_D.recordView->currentItem() ) SC_D.recordView->currentItem()->widget()->setCurrentMarker(nullptr); + + // Update mode indicator for accessibility + SC_D.modeLabel->setText(tr("Mode: View")); + announceToScreenReader(tr("Picking mode disabled. Now in view mode.")); } // <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<< @@ -8283,8 +9175,16 @@ void PickerView::pickNone(bool) { // >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> -void PickerView::pickS(bool) { +void PickerView::pickS(bool checked) { setCursorText("S"); + // Update mode indicator for accessibility + if (checked) { + SC_D.modeLabel->setText(tr("Mode: Pick S phase")); + announceToScreenReader(tr("S phase picking mode enabled. Click on waveform or press Space to set pick.")); + } else { + SC_D.modeLabel->setText(tr("Mode: View")); + announceToScreenReader(tr("S phase picking mode disabled. Now in view mode.")); + } } // <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<< @@ -8672,6 +9572,54 @@ void PickerView::gotoNextMarker() { SC_D.centerSelection = oldCenter; ensureVisibility(m->correctedTime(), 5); + + // Announce marker navigation for screen readers + // First announce pick info if it's a pick/arrival, then navigation + PickerMarker *pickerMarker = static_cast(m); + QString phaseName = pickerMarker->text(); + QString timeStr = QString::fromStdString(m->correctedTime().toString("%H:%M:%S.%f")); + QString markerType; + + if ( pickerMarker->isArrival() ) { + markerType = pickerMarker->pick() ? tr("pick") : tr("arrival"); + } else if ( pickerMarker->isPick() ) { + markerType = tr("manual pick"); + } else if ( pickerMarker->type() == PickerMarker::Theoretical ) { + markerType = tr("theoretical arrival"); + } else { + markerType = tr("marker"); + } + + // For picks and arrivals, compute and announce pick info first, then navigation + if ( pickerMarker->isArrival() || pickerMarker->isPick() ) { + // Try to compute amplitude and SNR from current record + double amplitude = 0.0, snr = -1.0; + QString quality = "unknown"; + + // First announce pick info with amplitude/SNR + if ( computePickAmplitudeAndSNR(SC_D.currentRecord, m->correctedTime(), amplitude, snr, quality) ) { + if ( snr > 0 ) { + announceToScreenReader(tr("Picked %1 at %2, amplitude %3, SNR %4, quality %5") + .arg(phaseName) + .arg(timeStr) + .arg(amplitude, 0, 'e', 2) + .arg(snr, 0, 'f', 1) + .arg(quality)); + } else { + announceToScreenReader(tr("Picked %1 at %2, amplitude %3") + .arg(phaseName) + .arg(timeStr) + .arg(amplitude, 0, 'e', 2)); + } + } + + // Then announce navigation context + announceToScreenReader(tr("Navigated to %1 %2 at %3").arg(phaseName).arg(markerType).arg(timeStr)); + } else { + announceToScreenReader(tr("Navigated to %1 %2 at %3").arg(phaseName).arg(markerType).arg(timeStr)); + } + } else { + announceToScreenReader(tr("No more %1 markers on this trace").arg(active ? tr("phase") : tr("marker"))); } } // <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<< @@ -8693,6 +9641,54 @@ void PickerView::gotoPreviousMarker() { SC_D.centerSelection = oldCenter; ensureVisibility(m->correctedTime(), 5); + + // Announce marker navigation for screen readers + // First announce pick info if it's a pick/arrival, then navigation + PickerMarker *pickerMarker = static_cast(m); + QString phaseName = pickerMarker->text(); + QString timeStr = QString::fromStdString(m->correctedTime().toString("%H:%M:%S.%f")); + QString markerType; + + if ( pickerMarker->isArrival() ) { + markerType = pickerMarker->pick() ? tr("pick") : tr("arrival"); + } else if ( pickerMarker->isPick() ) { + markerType = tr("manual pick"); + } else if ( pickerMarker->type() == PickerMarker::Theoretical ) { + markerType = tr("theoretical arrival"); + } else { + markerType = tr("marker"); + } + + // For picks and arrivals, compute and announce pick info first, then navigation + if ( pickerMarker->isArrival() || pickerMarker->isPick() ) { + // Try to compute amplitude and SNR from current record + double amplitude = 0.0, snr = -1.0; + QString quality = "unknown"; + + // First announce pick info with amplitude/SNR + if ( computePickAmplitudeAndSNR(SC_D.currentRecord, m->correctedTime(), amplitude, snr, quality) ) { + if ( snr > 0 ) { + announceToScreenReader(tr("Picked %1 at %2, amplitude %3, SNR %4, quality %5") + .arg(phaseName) + .arg(timeStr) + .arg(amplitude, 0, 'e', 2) + .arg(snr, 0, 'f', 1) + .arg(quality)); + } else { + announceToScreenReader(tr("Picked %1 at %2, amplitude %3") + .arg(phaseName) + .arg(timeStr) + .arg(amplitude, 0, 'e', 2)); + } + } + + // Then announce navigation context + announceToScreenReader(tr("Navigated to %1 %2 at %3").arg(phaseName).arg(markerType).arg(timeStr)); + } else { + announceToScreenReader(tr("Navigated to %1 %2 at %3").arg(phaseName).arg(markerType).arg(timeStr)); + } + } else { + announceToScreenReader(tr("No previous %1 markers on this trace").arg(active ? tr("phase") : tr("marker"))); } } // <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<< @@ -8700,6 +9696,94 @@ void PickerView::gotoPreviousMarker() { +// >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> +void PickerView::announceToScreenReader(const QString &message) { + // Announce message to screen readers using Qt accessibility APIs + // This provides feedback for Orca and other screen readers + + // Method 1: Status bar message (for debugging and visual feedback) + statusBar()->showMessage(message, 5000); + + // Always log for debugging + SEISCOMP_DEBUG("Screen reader announcement: %s", message.toStdString().c_str()); + + // Check accessibility status + bool accessibilityActive = QAccessible::isActive(); + SEISCOMP_DEBUG("Accessibility active: %s", accessibilityActive ? "yes" : "no"); + + // Method 2: Use QAccessibleAnnouncementEvent (Qt 6.8+) or fallback for older Qt +#if QT_VERSION >= QT_VERSION_CHECK(6, 8, 0) + // Qt 6.8+ has proper announcement support + // Send the announcement regardless of isActive() state + { + QAccessibleAnnouncementEvent event(SC_D.ui.centralwidget, message); + + // Use appropriate politeness based on message content + // Assertive for critical pick events, Polite for cursor/position updates + if ( message.contains("Created") || message.contains("Updated") || + message.contains("Deleted") || message.contains("phase picking mode") || + message.contains("Quality") || message.contains("SNR") || + message.contains("pick at") ) { + // Critical events - announce immediately + event.setPoliteness(QAccessible::AnnouncementPoliteness::Assertive); + SEISCOMP_DEBUG("Sending ASSERTIVE announcement"); + } else { + // Normal updates - announce at natural break + event.setPoliteness(QAccessible::AnnouncementPoliteness::Polite); + SEISCOMP_DEBUG("Sending POLITE announcement"); + } + + // Ensure the widget has accessibility interface + QAccessibleInterface *iface = QAccessible::queryAccessibleInterface(SC_D.ui.centralwidget); + if ( iface ) { + SEISCOMP_DEBUG("Widget has accessible interface: %s", iface->text(QAccessible::Name).toStdString().c_str()); + } else { + SEISCOMP_DEBUG("Widget does NOT have accessible interface"); + } + + QAccessible::updateAccessibility(&event); + SEISCOMP_DEBUG("Accessibility announcement sent (Qt6.8+ QAccessibleAnnouncementEvent)"); + } + + // Also try sending via QAccessible::Announcement event type directly + { + QAccessibleEvent directEvent(SC_D.ui.centralwidget, QAccessible::Announcement); + QAccessible::updateAccessibility(&directEvent); + SEISCOMP_DEBUG("Sent direct QAccessible::Announcement event"); + } +#else + // Fallback for Qt 5.x - 6.7 + if ( accessibilityActive ) { + // Create a text inserted event - this is what Orca listens for + QAccessibleTextInsertEvent event(SC_D.ui.centralwidget, 0, message); + QAccessible::updateAccessibility(&event); + + // Also try alert event + QAccessibleEvent alertEvent(SC_D.ui.centralwidget, QAccessible::Alert); + QAccessible::updateAccessibility(&alertEvent); + + SEISCOMP_DEBUG("Accessibility announcement sent (Qt5-6.7 fallback)"); + } else { + SEISCOMP_DEBUG("Accessibility not active, announcement skipped"); + } +#endif +} +// <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<< + + +// >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> +void PickerView::delayedQualityAnnouncement(const QString &message) { + // Use Qt's singleShot to delay the announcement by 100ms + // This ensures Orca has time to process the previous announcement + QMetaObject::invokeMethod(this, "announceToScreenReader", + Qt::QueuedConnection, + Q_ARG(QString, message)); +} +// <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<< + + + + // >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> bool PickerView::hasModifiedPicks() const { for ( int r = 0; r < SC_D.recordView->rowCount(); ++r ) { @@ -9733,10 +10817,16 @@ void PickerView::deletePick() { if ( old ) old->setEnabled(true); } - if ( m->isMovable() || !SC_D.loadedPicks ) + QString phaseName = m->text(); + QString stationCode = item->label()->text(0); + if ( m->isMovable() || !SC_D.loadedPicks ) { delete m; - else + announceToScreenReader(tr("Deleted %1 pick at station %2").arg(phaseName).arg(stationCode)); + } + else { m->setType(PickerMarker::Pick); + announceToScreenReader(tr("Converted %1 arrival to manual pick").arg(phaseName)); + } item->widget()->update(); SC_D.currentRecord->update(); @@ -9751,6 +10841,70 @@ void PickerView::deletePick() { +// >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> +void PickerView::announceCurrentPickDetails() { + RecordViewItem *item = SC_D.recordView->currentItem(); + if ( !item ) { + announceToScreenReader(tr("No station selected")); + return; + } + + RecordWidget* widget = item->widget(); + if ( !widget ) { + announceToScreenReader(tr("No waveform data for selected station")); + return; + } + + // Get the current marker (pick) if one exists + PickerMarker* marker = static_cast(widget->currentMarker()); + if ( !marker ) { + // Try to get marker at cursor position + marker = static_cast(widget->marker(widget->cursorText())); + } + + if ( !marker ) { + // No pick at current position, announce cursor position with amplitude/SNR + OPT(Core::Time) cursorTime = widget->cursorPos(); + if ( !cursorTime ) { + announceToScreenReader(tr("No pick at current position and no cursor time")); + return; + } + + // Announce amplitude and SNR at cursor position + double amplitude = 0.0, snr = -1.0; + QString quality = "unknown"; + + if ( computePickAmplitudeAndSNR(widget, *cursorTime, amplitude, snr, quality) ) { + QString timeStr = QString::fromStdString(cursorTime->toString("%H:%M:%S.%f")); + QString stationCode = item->label()->text(0); + + if ( snr > 0 ) { + announceToScreenReader(tr("Cursor at %1, station %2, amplitude %3, SNR %4, quality %5") + .arg(timeStr) + .arg(stationCode) + .arg(amplitude, 0, 'e', 2) + .arg(snr, 0, 'f', 1) + .arg(quality)); + } else { + announceToScreenReader(tr("Cursor at %1, station %2, amplitude %3") + .arg(timeStr) + .arg(stationCode) + .arg(amplitude, 0, 'e', 2)); + } + } else { + announceToScreenReader(tr("No pick at current position and unable to compute amplitude")); + } + return; + } + + // Announce pick details + announcePickDetails(widget, marker->correctedTime(), marker->text(), false); +} +// <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<< + + + + // >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> void PickerView::addFilter(const QString& name, const QString& filter) { if ( SC_D.comboFilter ) { @@ -10471,6 +11625,170 @@ bool PickerView::setArrivalState(RecordWidget* w, int arrivalId, bool state) { // <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<< +// >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> +// Audio Sonification Implementation +// >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> + +void PickerView::toggleAudioSonification() { + SC_D.audioSonification->setEnabled(!SC_D.audioSonification->isEnabled()); + + SC_D.ui.actionToggleAudioSonification->setChecked(SC_D.audioSonification->isEnabled()); + + if ( SC_D.audioSonification->isEnabled() ) { + statusBar()->showMessage(tr("Audio sonification enabled - press Ctrl+Space to play"), 5000); + } + else { + statusBar()->showMessage(tr("Audio sonification disabled"), 5000); + } +} + +void PickerView::playCurrentTraceAudio() { + if ( !SC_D.audioSonification->isEnabled() ) { + statusBar()->showMessage(tr("Audio sonification is disabled - enable with Ctrl+Shift+A first"), 5000); + return; + } + + if ( !SC_D.currentRecord ) { + return; + } + + auto seq = SC_D.currentRecord->isFilteringEnabled() + ? SC_D.currentRecord->filteredRecords(SC_D.currentSlot) + : SC_D.currentRecord->records(SC_D.currentSlot); + + if ( !seq ) { + statusBar()->showMessage(tr("No waveform data available for sonification"), 5000); + return; + } + + std::vector waveformData; + double originalSampleRate = 0.0; + + for ( auto it = seq->begin(); it != seq->end(); ++it ) { + auto rec = *it; + if ( !rec || !rec->data() ) continue; + + if ( originalSampleRate == 0.0 ) { + originalSampleRate = rec->samplingFrequency(); + } + + auto dArray = DoubleArray::ConstCast(rec->data()); + if ( dArray ) { + for ( int i = 0; i < dArray->size(); ++i ) { + waveformData.push_back((*dArray)[i]); + } + } + else { + auto fArray = FloatArray::ConstCast(rec->data()); + if ( fArray ) { + for ( int i = 0; i < fArray->size(); ++i ) { + waveformData.push_back(static_cast((*fArray)[i])); + } + } + } + } + + if ( waveformData.empty() ) { + statusBar()->showMessage(tr("No waveform data available for sonification"), 5000); + return; + } + + SC_D.audioSonification->setWaveformData(waveformData, originalSampleRate, 160.0f); + + double dataDuration = SC_D.audioSonification->dataDurationSec(); + int audioDurationMs = SC_D.audioSonification->audioDurationMs(); + + statusBar()->showMessage( + tr("Playing %1s of seismic data at %2 Hz, speeded up 160x (audio: %3 ms)") + .arg(dataDuration, 0, 'f', 1) + .arg(originalSampleRate, 0, 'f', 1) + .arg(audioDurationMs), + 5000 + ); + + SC_D.audioSonification->play(); +} + +void PickerView::stopAudioPlayback() { + SC_D.audioSonification->stop(); + statusBar()->showMessage(tr("Audio playback stopped"), 3000); +} + +void PickerView::playAudioAtCursor() { + if ( !SC_D.audioSonification->isEnabled() ) { + return; + } + + if ( !SC_D.currentRecord ) { + return; + } + + auto seq = SC_D.currentRecord->isFilteringEnabled() + ? SC_D.currentRecord->filteredRecords(SC_D.currentSlot) + : SC_D.currentRecord->records(SC_D.currentSlot); + + if ( !seq ) { + return; + } + + double windowSecs = 15.0; + Core::Time cursorTime = SC_D.currentRecord->cursorPos(); + Core::Time startTime = cursorTime - Core::TimeSpan(windowSecs * 0.2); + Core::Time endTime = cursorTime + Core::TimeSpan(windowSecs * 0.8); + + std::vector waveformData; + double originalSampleRate = 0.0; + + for ( auto it = seq->begin(); it != seq->end(); ++it ) { + auto rec = *it; + if ( !rec || !rec->data() ) continue; + + if ( originalSampleRate == 0.0 ) { + originalSampleRate = rec->samplingFrequency(); + } + + if ( rec->endTime() < startTime || rec->startTime() > endTime ) { + continue; + } + + auto dArray = DoubleArray::ConstCast(rec->data()); + if ( dArray ) { + double fs = rec->samplingFrequency(); + int startIdx = std::max(0, static_cast( + static_cast(startTime - rec->startTime()) * fs)); + int endIdx = std::min(dArray->size(), static_cast( + static_cast(endTime - rec->startTime()) * fs) + 1); + + for ( int i = startIdx; i < endIdx; ++i ) { + waveformData.push_back((*dArray)[i]); + } + } + else { + auto fArray = FloatArray::ConstCast(rec->data()); + if ( fArray ) { + double fs = rec->samplingFrequency(); + int startIdx = std::max(0, static_cast( + static_cast(startTime - rec->startTime()) * fs)); + int endIdx = std::min(fArray->size(), static_cast( + static_cast(endTime - rec->startTime()) * fs) + 1); + + for ( int i = startIdx; i < endIdx; ++i ) { + waveformData.push_back(static_cast((*fArray)[i])); + } + } + } + } + + if ( waveformData.size() < 10 ) { + return; + } + + SC_D.audioSonification->setWaveformData(waveformData, originalSampleRate, 80.0f); + + SC_D.audioSonification->play(); +} +// <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<< + // >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> diff --git a/libs/seiscomp/gui/datamodel/pickerview.h b/libs/seiscomp/gui/datamodel/pickerview.h index 26e117ab5..e13353fae 100644 --- a/libs/seiscomp/gui/datamodel/pickerview.h +++ b/libs/seiscomp/gui/datamodel/pickerview.h @@ -42,6 +42,7 @@ #include #include #include +#include #include @@ -64,6 +65,7 @@ namespace Gui { class TimeScale; class PickerView; class SpectrumWidget; +class WaveformAudio; namespace PrivatePickerView { @@ -411,6 +413,11 @@ class SC_GUI_API PickerView : public QMainWindow { const std::string& stationCode, bool state); + void toggleAudioSonification(); + void playCurrentTraceAudio(); + void stopAudioPlayback(); + void playAudioAtCursor(); + private slots: void receivedRecord(Seiscomp::Record*); @@ -495,11 +502,15 @@ class SC_GUI_API PickerView : public QMainWindow { void gotoNextMarker(); void gotoPreviousMarker(); + void announceToScreenReader(const QString &message); + void delayedQualityAnnouncement(const QString &message); + void createPick(); void setPick(); void confirmPick(); void resetPick(); void deletePick(); + void announceCurrentPickDetails(); void setCurrentRowEnabled(bool); void setCurrentRowDisabled(bool); @@ -558,16 +569,15 @@ class SC_GUI_API PickerView : public QMainWindow { protected: void showEvent(QShowEvent* event) override; void changeEvent(QEvent *e) override; + void keyPressEvent(QKeyEvent *event) override; RecordLabel* createLabel(RecordViewItem*) const; private: -<<<<<<< HEAD -======= void applyThemeColors(); ->>>>>>> e0ce74329ae8520ae62312544942fb745c885087 void announceAmplitude(); + void announcePickDetails(RecordWidget* widget, const Core::Time& pickTime, const QString& phaseName, bool isUpdate = false); void figureOutTravelTimeTable(); void updateTransformations(PrivatePickerView::PickerRecordLabel *label); diff --git a/libs/seiscomp/gui/datamodel/pickerview.ui b/libs/seiscomp/gui/datamodel/pickerview.ui index 36277e35a..ca223e4cc 100644 --- a/libs/seiscomp/gui/datamodel/pickerview.ui +++ b/libs/seiscomp/gui/datamodel/pickerview.ui @@ -13,6 +13,12 @@ Picker + + Seismic Phase Picker + + + Interactive tool for viewing seismic waveforms and picking P and S phase arrivals + 16 @@ -41,6 +47,12 @@ Qt::Vertical + + Waveform display splitter + + + Adjustable split view with zoom trace on top and station list below + QFrame::StyledPanel @@ -48,6 +60,12 @@ QFrame::Sunken + + Zoom trace view + + + Detailed waveform view with zoom controls for phase picking + 6 @@ -108,6 +126,12 @@ QFrame::Plain + + Current station information + + + Displays station code, channel, distance, azimuth, and amplitude level for the currently selected trace + 6 @@ -135,6 +159,12 @@ ABCD + + Station code + + + Four-character seismic station identifier + @@ -154,38 +184,6 @@ 0 - - - - - 0 - 0 - - - - ABCD - - - - - - - - 0 - 0 - - - - 0 - - - 100 - - - - - - @@ -203,6 +201,12 @@ AB BHZ + + Channel code + + + Network, location, and channel codes identifying the seismic component + @@ -210,6 +214,9 @@ , distance: + + Distance label + @@ -217,6 +224,12 @@ -1° + + Epicentral distance + + + Distance from earthquake to station in degrees + @@ -224,6 +237,9 @@ , azimuth: + + Azimuth label + @@ -231,6 +247,12 @@ -1° + + Azimuth + + + Direction from earthquake to station in degrees + @@ -265,6 +287,12 @@ 0 + + Amplitude level indicator + + + Visual indicator of current signal amplitude level + @@ -284,6 +312,12 @@ QFrame::Raised + + Detailed waveform display + + + Main waveform display area showing three-component seismic data for detailed picking + @@ -317,6 +351,12 @@ QFrame::Raised + + Picking control buttons + + + Buttons to accept, deactivate, or reset picks for the current trace + 6 @@ -356,6 +396,12 @@ Accept picked phase + + Accept pick + + + Confirm and save the current phase pick + @@ -396,6 +442,12 @@ Deactivate current pick or trace depending on the picking mode + + Deactivate pick or trace + + + Remove or deactivate the current pick or disable the trace + @@ -436,6 +488,12 @@ Reset manual picks + + Reset manual picks + + + Clear all manual picks and restore to original state + @@ -480,16 +538,32 @@ QFrame::Sunken + + Station list view + + + List of all seismic stations with their picks and arrival times + - + + + Status bar showing current station, channel, and picking information + + Zooming + + Amplitude and time scale controls + + + Tools for adjusting the zoom level and amplitude scaling of seismic traces + Qt::Horizontal @@ -517,6 +591,12 @@ Sort + + Station sorting options + + + Sort seismic stations by distance, azimuth, name, or residual + Qt::Horizontal @@ -541,6 +621,12 @@ Alignment + + Trace alignment options + + + Align seismic traces on origin time or P and S arrivals + Qt::Horizontal @@ -564,6 +650,12 @@ Components + + Component visibility controls + + + Show or hide vertical (Z), north (N), and east (E) seismic components + Qt::Horizontal @@ -593,6 +685,12 @@ Add stations + + Station management + + + Add nearby stations and control which stations are displayed + Qt::Horizontal @@ -618,6 +716,12 @@ true + + Phase picking controls + + + Tools for picking P and S seismic phases on waveform traces + Qt::Horizontal @@ -640,6 +744,12 @@ Filter + + Filter selection + + + Apply frequency filters to seismic data + Qt::Horizontal @@ -660,29 +770,15 @@ Travel times - - Qt::Horizontal + + Travel time table selection - - TopToolBarArea - - - false - - - - - Apply + + Select travel time model for calculating theoretical arrival times Qt::Horizontal - - - 24 - 24 - - TopToolBarArea @@ -690,7 +786,53 @@ false - + + + Apply + + + Apply changes and relocate + + + Apply pick changes to the earthquake origin and recalculate residuals + + + Qt::Horizontal + + + + 24 + 24 + + + + TopToolBarArea + + + false + + + + + Sonification + + + Qt::Horizontal + + + + 24 + 24 + + + + TopToolBarArea + + + false + + + 0 @@ -699,10 +841,22 @@ 30 + + Main menu bar + + + Access to all PickerView functions including view options, navigation, picking, filtering, and tools + &Filter + + Filter menu + + + Filter control options including toggle, next, previous, and limit to zoom trace + @@ -712,6 +866,12 @@ &Locator + + Locator menu + + + Earthquake location tools including relocate and modify origin + @@ -719,12 +879,28 @@ &Tools + + Tools menu + + + Additional analysis tools including spectrum view + + + + + &Picking + + Picking menu + + + Phase picking options including automatic repicking + @@ -732,6 +908,12 @@ &Navigation + + Navigation menu + + + Scroll and navigate through seismic traces + @@ -741,10 +923,22 @@ &View + + View menu + + + Display options for traces, components, scaling, and alignment + &Components + + Components submenu + + + Control which seismic components (Z, N, E) are displayed + @@ -753,6 +947,12 @@ &Scaling + + Scaling submenu + + + Adjust trace amplitude and time scale + @@ -763,12 +963,24 @@ &Alignment + + Alignment submenu + + + Align traces on origin time or specific phases + &Zoom trace + + Zoom trace submenu + + + Detailed zoom and scale controls for the selected trace + @@ -795,6 +1007,12 @@ &Window + + Window menu + + + Window management options + @@ -1126,6 +1344,9 @@ Pick P phase + + Enable P phase picking mode. Click on the waveform or press Space to set a P phase pick at the cursor position + F1 @@ -1137,6 +1358,9 @@ Pick S phase + + Enable S phase picking mode. Click on the waveform or press Space to set an S phase pick at the cursor position + F2 @@ -1177,6 +1401,20 @@ Del + + + Announce pick details + + + Announce current pick details with SNR and amplitude (Ctrl+Shift+D) + + + Announce current pick details including signal-to-noise ratio, amplitude, and quality information for screen reader users + + + Ctrl+Shift+D + + &Apply all @@ -1460,6 +1698,45 @@ Space + + + Toggle audio sonification + + + Toggle audio + + + Enable/disable audio sonification of waveforms (Ctrl+Shift+A) + + + Ctrl+Shift+A + + + + + Play trace audio + + + Play audio + + + Play current trace as audio (Ctrl+Space) + + + Ctrl+Space + + + + + Stop audio playback + + + Stop current audio sonification playback (Ctrl+Shift+Space) + + + Ctrl+Shift+Space + + true diff --git a/libs/seiscomp/gui/datamodel/pickerview_accessibility.cpp b/libs/seiscomp/gui/datamodel/pickerview_accessibility.cpp new file mode 100644 index 000000000..5465d3fd8 --- /dev/null +++ b/libs/seiscomp/gui/datamodel/pickerview_accessibility.cpp @@ -0,0 +1,541 @@ +/*************************************************************************** + * Copyright (C) gempa GmbH * + * All rights reserved. * + * Contact: gempa GmbH (seiscomp-dev@gempa.de) * + * * + * GNU Affero General Public License Usage * + * This file may be used under the terms of the GNU Affero * + * Public License version 3.0 as published by the Free Software Foundation * + * and appearing in the file LICENSE included in the packaging of this * + * file. Please review the following information to ensure the GNU Affero * + * Public License version 3.0 requirements will be met: * + * https://www.gnu.org/licenses/agpl-3.0.html. * + * * + * Other Usage * + * Alternatively, this file may be used in accordance with the terms and * + * conditions contained in a signed written agreement between you and * + * gempa GmbH. * + ***************************************************************************/ + + +#include "pickerview_accessibility.h" + +#include +#include + +#include + +// Define indices used in pickerview.cpp +#define ITEM_DISTANCE_INDEX 0 +#define ITEM_RESIDUAL_INDEX 1 +#define ITEM_AZIMUTH_INDEX 2 + +namespace Seiscomp { +namespace Gui { + + +// >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> +RecordWidgetAccessible::RecordWidgetAccessible(RecordWidget *widget) +: QAccessibleWidget(widget, QAccessible::Client, "WaveformDisplay") +{ +} +// <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<< + + +// >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> +RecordWidgetAccessible::~RecordWidgetAccessible() +{ +} +// <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<< + + +// >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> +QString RecordWidgetAccessible::text(QAccessible::Text t) const +{ + RecordWidget *w = recordWidget(); + if ( !w ) return QString(); + + switch ( t ) { + case QAccessible::Name: + return tr("Seismic Waveform Display"); + + case QAccessible::Description: { + QString desc; + desc += tr("Waveform display showing seismic data"); + + // Add time range + Core::Time startTime = w->leftTime(); + Core::Time endTime = w->rightTime(); + desc += tr(". Time range: %1 to %2").arg( + startTime.toString("%H:%M:%S").c_str(), + endTime.toString("%H:%M:%S").c_str() + ); + + // Add trace count + int traceCount = w->slotCount(); + desc += tr(". %1 trace%2").arg(traceCount).arg(traceCount > 1 ? "s" : ""); + + // Add cursor position if active + if ( !w->cursorText().isEmpty() ) { + Core::Time cursorTime = w->cursorPos(); + desc += tr(". Cursor at %1 in %2 mode").arg( + cursorTime.toString("%H:%M:%S.%f").c_str(), + w->cursorText() + ); + } + + return desc; + } + + default: + break; + } + + return QString(); +} +// <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<< + + +// >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> +QAccessible::Role RecordWidgetAccessible::role() const +{ + return QAccessible::Client; +} +// <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<< + + +// >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> +QAccessible::State RecordWidgetAccessible::state() const +{ + QAccessible::State state; + RecordWidget *w = recordWidget(); + + if ( !w ) return state; + + state.active = w->isActive(); + state.focusable = true; + state.focused = w->hasFocus(); + + return state; +} +// <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<< + + +// >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> +QRect RecordWidgetAccessible::rect() const +{ + RecordWidget *w = recordWidget(); + return w ? w->geometry() : QRect(); +} +// <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<< + + +// >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> +int RecordWidgetAccessible::childCount() const +{ + // The waveform display itself doesn't have accessible children + // Individual traces are handled by RecordViewItemAccessible + return 0; +} +// <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<< + + +// >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> +QAccessibleInterface *RecordWidgetAccessible::child(int /*index*/) const +{ + return nullptr; +} +// <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<< + + +// >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> +QAccessibleInterface *RecordWidgetAccessible::parent() const +{ + return QAccessibleWidget::parent(); +} +// <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<< + + +// >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> +QAccessibleInterface *RecordWidgetAccessible::focusChild() const +{ + return nullptr; +} +// <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<< + + +// >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> +RecordWidget *RecordWidgetAccessible::recordWidget() const +{ + return qobject_cast(object()); +} +// <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<< + + +// >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> +RecordViewItemAccessible::RecordViewItemAccessible(RecordViewItem *item) +: QAccessibleWidget(item->widget(), QAccessible::ListItem, "StationTrace") +{ +} +// <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<< + + +// >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> +RecordViewItemAccessible::~RecordViewItemAccessible() +{ +} +// <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<< + + +// >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> +QString RecordViewItemAccessible::text(QAccessible::Text t) const +{ + RecordViewItem *item = recordViewItem(); + if ( !item ) return QString(); + + switch ( t ) { + case QAccessible::Name: { + // Build station name from label + QString stationCode = item->label()->text(0); + return tr("Station %1").arg(stationCode); + } + + case QAccessible::Description: + return buildDescription(); + + default: + break; + } + + return QString(); +} +// <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<< + + +// >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> +QAccessible::Role RecordViewItemAccessible::role() const +{ + return QAccessible::ListItem; +} +// <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<< + + +// >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> +QAccessible::State RecordViewItemAccessible::state() const +{ + QAccessible::State state; + RecordViewItem *item = recordViewItem(); + + if ( !item ) return state; + + RecordWidget *w = item->widget(); + if ( !w ) return state; + + state.active = item->isSelected(); + state.selected = item->isSelected(); + state.focusable = true; + state.focused = w->hasFocus(); + + return state; +} +// <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<< + + +// >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> +QRect RecordViewItemAccessible::rect() const +{ + RecordViewItem *item = recordViewItem(); + return item ? item->geometry() : QRect(); +} +// <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<< + + +// >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> +int RecordViewItemAccessible::childCount() const +{ + // Individual trace components could be children + // For now, keep it simple + return 0; +} +// <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<< + + +// >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> +QAccessibleInterface *RecordViewItemAccessible::child(int /*index*/) const +{ + return nullptr; +} +// <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<< + + +// >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> +QAccessibleInterface *RecordViewItemAccessible::parent() const +{ + // Return the parent RecordView's accessibility interface + // We need to find the RecordView through the widget's parent hierarchy + RecordViewItem *item = recordViewItem(); + if ( !item ) return nullptr; + + RecordWidget *w = item->widget(); + if ( !w ) return nullptr; + + // Try to find the RecordView parent + QWidget *parentWidget = w->parentWidget(); + while ( parentWidget ) { + if ( RecordView *view = qobject_cast(parentWidget) ) { + return QAccessible::queryAccessibleInterface(view); + } + parentWidget = parentWidget->parentWidget(); + } + + return nullptr; +} +// <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<< + + +// >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> +RecordViewItem *RecordViewItemAccessible::recordViewItem() const +{ + return qobject_cast(object()); +} +// <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<< + + +// >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> +QString RecordViewItemAccessible::buildDescription() const +{ + RecordViewItem *item = recordViewItem(); + if ( !item ) return QString(); + + QString desc; + + // Station code + QString stationCode = item->label()->text(0); + desc += tr("Station %1").arg(stationCode); + + // Channel info + QString channel = item->label()->text(1); + if ( !channel.isEmpty() ) { + desc += tr(", channel %1").arg(channel); + } + + // Distance + QString distance = item->label()->text(ITEM_DISTANCE_INDEX); + if ( !distance.isEmpty() ) { + desc += tr(", distance %1").arg(distance); + } + + // Azimuth + QString azimuth = item->label()->text(ITEM_AZIMUTH_INDEX); + if ( !azimuth.isEmpty() ) { + desc += tr(", azimuth %1").arg(azimuth); + } + + // Residual (if showing arrivals) + QString residual = item->label()->text(ITEM_RESIDUAL_INDEX); + if ( !residual.isEmpty() && residual != "-" ) { + desc += tr(", residual %1").arg(residual); + } + + // State + RecordWidget *w = item->widget(); + if ( w ) { + if ( !w->isEnabled() ) { + desc += tr(" (disabled)"); + } + } + + // Pick information + if ( w ) { + int pickCount = 0; + QStringList phases; + for ( int m = 0; m < w->markerCount(); ++m ) { + RecordMarker *marker = w->marker(m); + if ( marker ) { + ++pickCount; + if ( !phases.contains(marker->text()) ) { + phases.append(marker->text()); + } + } + } + + if ( pickCount > 0 ) { + desc += tr(". %1 pick%2: %3").arg(pickCount) + .arg(pickCount > 1 ? "s" : "") + .arg(phases.join(", ")); + } + } + + return desc; +} +// <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<< + + +// >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> +RecordViewAccessible::RecordViewAccessible(RecordView *view) +: QAccessibleWidget(view, QAccessible::List, "StationList") +{ +} +// <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<< + + +// >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> +RecordViewAccessible::~RecordViewAccessible() +{ +} +// <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<< + + +// >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> +QString RecordViewAccessible::text(QAccessible::Text t) const +{ + RecordView *view = recordView(); + if ( !view ) return QString(); + + switch ( t ) { + case QAccessible::Name: + return tr("Seismic Station List"); + + case QAccessible::Description: { + QString desc; + desc += tr("List of seismic stations with waveform traces"); + + int count = view->rowCount(); + desc += tr(". %1 station%2").arg(count).arg(count > 1 ? "s" : ""); + + // Count enabled stations + int enabledCount = 0; + for ( int i = 0; i < count; ++i ) { + RecordViewItem *item = view->itemAt(i); + if ( item && item->widget() && item->widget()->isEnabled() ) { + ++enabledCount; + } + } + desc += tr(" (%1 enabled)").arg(enabledCount); + + return desc; + } + + default: + break; + } + + return QString(); +} +// <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<< + + +// >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> +QAccessible::Role RecordViewAccessible::role() const +{ + return QAccessible::List; +} +// <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<< + + +// >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> +QAccessible::State RecordViewAccessible::state() const +{ + QAccessible::State state; + RecordView *view = recordView(); + + if ( !view ) return state; + + state.active = true; + state.focusable = true; + state.focused = view->hasFocus(); + state.multiSelectable = true; + + return state; +} +// <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<< + + +// >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> +QRect RecordViewAccessible::rect() const +{ + RecordView *view = recordView(); + return view ? view->geometry() : QRect(); +} +// <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<< + + +// >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> +int RecordViewAccessible::childCount() const +{ + RecordView *view = recordView(); + return view ? view->rowCount() : 0; +} +// <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<< + + +// >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> +QAccessibleInterface *RecordViewAccessible::child(int index) const +{ + RecordView *view = recordView(); + if ( !view || index < 0 || index >= view->rowCount() ) return nullptr; + + RecordViewItem *item = view->itemAt(index); + if ( !item ) return nullptr; + + return QAccessible::queryAccessibleInterface(item); +} +// <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<< + + +// >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> +QAccessibleInterface *RecordViewAccessible::parent() const +{ + return QAccessibleWidget::parent(); +} +// <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<< + + +// >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> +QAccessibleInterface *RecordViewAccessible::focusChild() const +{ + RecordView *view = recordView(); + if ( !view ) return nullptr; + + RecordViewItem *currentItem = view->currentItem(); + if ( !currentItem ) return nullptr; + + return QAccessible::queryAccessibleInterface(currentItem); +} +// <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<< + + +// >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> +RecordView *RecordViewAccessible::recordView() const +{ + return qobject_cast(object()); +} +// <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<< + + +// >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> +QAccessibleInterface *pickerViewAccessibleFactory(const QString &key, QObject *object) +{ + if ( key == QLatin1String("RecordWidget") ) { + if ( RecordWidget *widget = qobject_cast(object) ) { + return new RecordWidgetAccessible(widget); + } + } + else if ( key == QLatin1String("RecordViewItem") ) { + if ( RecordViewItem *item = qobject_cast(object) ) { + return new RecordViewItemAccessible(item); + } + } + else if ( key == QLatin1String("RecordView") ) { + if ( RecordView *view = qobject_cast(object) ) { + return new RecordViewAccessible(view); + } + } + + return nullptr; +} +// <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<< + + +} // namespace Gui +} // namespace Seiscomp diff --git a/libs/seiscomp/gui/datamodel/pickerview_accessibility.h b/libs/seiscomp/gui/datamodel/pickerview_accessibility.h new file mode 100644 index 000000000..bac654879 --- /dev/null +++ b/libs/seiscomp/gui/datamodel/pickerview_accessibility.h @@ -0,0 +1,168 @@ +/*************************************************************************** + * Copyright (C) gempa GmbH * + * All rights reserved. * + * Contact: gempa GmbH (seiscomp-dev@gempa.de) * + * * + * GNU Affero General Public License Usage * + * This file may be used under the terms of the GNU Affero * + * Public License version 3.0 as published by the Free Software Foundation * + * and appearing in the file LICENSE included in the packaging of this * + * file. Please review the following information to ensure the GNU Affero * + * Public License version 3.0 requirements will be met: * + * https://www.gnu.org/licenses/agpl-3.0.html. * + * * + * Other Usage * + * Alternatively, this file may be used in accordance with the terms and * + * conditions contained in a signed written agreement between you and * + * gempa GmbH. * + ***************************************************************************/ + + +#ifndef SEISCOMP_GUI_PICKERVIEW_ACCESSIBILITY_H +#define SEISCOMP_GUI_PICKERVIEW_ACCESSIBILITY_H + + +#include +#include +#include +#include +#include + + +namespace Seiscomp { +namespace Gui { + + +class RecordWidget; +class RecordViewItem; +class RecordView; + + +// Forward declarations +class RecordWidgetAccessible; +class RecordViewItemAccessible; +class RecordViewAccessible; + + +// >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> +/** + * Custom accessibility interface for RecordWidget (waveform display) + * + * Provides screen reader access to: + * - Current time range + * - Number of traces + * - Current cursor position + * - Active picks/markers + */ +class RecordWidgetAccessible : public QAccessibleWidget { +public: + explicit RecordWidgetAccessible(RecordWidget *widget); + + // QAccessibleInterface methods + QString text(QAccessible::Text t) const override; + QAccessible::Role role() const override; + QAccessible::State state() const override; + QRect rect() const override; + + // Hierarchy navigation + int childCount() const override; + QAccessibleInterface *child(int index) const override; + QAccessibleInterface *parent() const override; + + // Selection support + QAccessibleInterface *focusChild() const override; + +protected: + ~RecordWidgetAccessible() override; + +private: + RecordWidget *recordWidget() const; +}; +// <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<< + + +// >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> +/** + * Custom accessibility interface for RecordViewItem (station trace) + * + * Provides screen reader access to: + * - Station code + * - Channel code + * - Distance and azimuth + * - Pick information + * - Trace state (enabled/disabled) + */ +class RecordViewItemAccessible : public QAccessibleWidget { +public: + explicit RecordViewItemAccessible(RecordViewItem *item); + + // QAccessibleInterface methods + QString text(QAccessible::Text t) const override; + QAccessible::Role role() const override; + QAccessible::State state() const override; + QRect rect() const override; + + // Hierarchy navigation + int childCount() const override; + QAccessibleInterface *child(int index) const override; + QAccessibleInterface *parent() const override; + +protected: + ~RecordViewItemAccessible() override; + +private: + RecordViewItem *recordViewItem() const; + QString buildDescription() const; +}; +// <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<< + + +// >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> +/** + * Custom accessibility interface for RecordView (list of traces) + * + * Provides screen reader access to: + * - List of stations + * - Current selection + * - Total count + * - Navigation hints + */ +class RecordViewAccessible : public QAccessibleWidget { +public: + explicit RecordViewAccessible(RecordView *view); + + // QAccessibleInterface methods + QString text(QAccessible::Text t) const override; + QAccessible::Role role() const override; + QAccessible::State state() const override; + QRect rect() const override; + + // Hierarchy navigation + int childCount() const override; + QAccessibleInterface *child(int index) const override; + QAccessibleInterface *parent() const override; + + // Selection support + QAccessibleInterface *focusChild() const override; + +protected: + ~RecordViewAccessible() override; + +private: + RecordView *recordView() const; +}; +// <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<< + + +// >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> +/** + * Accessibility factory function + */ +QAccessibleInterface *pickerViewAccessibleFactory(const QString &key, QObject *object); + + +} // namespace Gui +} // namespace Seiscomp + + +#endif // SEISCOMP_GUI_PICKERVIEW_ACCESSIBILITY_H diff --git a/libs/seiscomp/gui/datamodel/pickerview_p.h b/libs/seiscomp/gui/datamodel/pickerview_p.h index 52c38f5a1..4835ec687 100644 --- a/libs/seiscomp/gui/datamodel/pickerview_p.h +++ b/libs/seiscomp/gui/datamodel/pickerview_p.h @@ -44,6 +44,7 @@ namespace Gui { class SpectrogramSettings; +class WaveformAudio; class PickerViewPrivate { @@ -158,6 +159,10 @@ class PickerViewPrivate { QMenu *auxiliaryProfileMenu{nullptr}; QMenu *auxiliaryProfileVisibilityMenu{nullptr}; + QLabel *modeLabel; + WaveformAudio *audioSonification{nullptr}; + QTimer *audioDebounceTimer{nullptr}; + ::Ui::PickerView ui; bool settingsRestored; From 51e54f796a25f464df451f6a8319074f0336f448 Mon Sep 17 00:00:00 2001 From: Donavin97 Date: Sat, 6 Jun 2026 14:48:04 +0000 Subject: [PATCH 08/23] Add screen reader announcements for trace switching, filter/unit/rotation changes - itemSelected now announces 'Station N of M' with station/channel/distance/azimuth - changeFilter announces the filter name (or 'No filter') - changeUnit announces the unit type - changeRotation announces the rotation mode --- libs/seiscomp/gui/datamodel/pickerview.cpp | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/libs/seiscomp/gui/datamodel/pickerview.cpp b/libs/seiscomp/gui/datamodel/pickerview.cpp index 5e7e896a0..40cf3218e 100644 --- a/libs/seiscomp/gui/datamodel/pickerview.cpp +++ b/libs/seiscomp/gui/datamodel/pickerview.cpp @@ -8615,7 +8615,17 @@ void PickerView::itemSelected(RecordViewItem* item, RecordViewItem* lastItem) { // Announce station change to screen readers QString distanceText = SC_D.ui.labelDistance->text(); QString azimuthText = SC_D.ui.labelAzimuth->text(); - announceToScreenReader(tr("Station %1, channel %2, distance %3, azimuth %4") + int rowIndex = -1; + int rowCount = SC_D.recordView->rowCount(); + for ( int r = 0; r < rowCount; ++r ) { + if ( SC_D.recordView->itemAt(r) == item ) { + rowIndex = r; + break; + } + } + announceToScreenReader(tr("Station %1 of %2: %3, channel %4, distance %5, azimuth %6") + .arg(rowIndex + 1) + .arg(rowCount) .arg(streamID.stationCode().c_str()) .arg(cha.c_str()) .arg(distanceText) @@ -11269,6 +11279,9 @@ void PickerView::changeRotation(int index) { } QApplication::restoreOverrideCursor(); + + announceToScreenReader(tr("Rotation changed to %1") + .arg(PickerView::Config::ERotationTypeNames::name(index))); } // <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<< @@ -11283,6 +11296,9 @@ void PickerView::changeUnit(int index) { applyFilter(); QApplication::restoreOverrideCursor(); + + announceToScreenReader(tr("Unit changed to %1") + .arg(PickerView::Config::EUnitTypeNames::name(index))); } // <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<< @@ -11522,6 +11538,8 @@ void PickerView::changeFilter(int index, bool) { SC_D.lastFilterIndex = index; QApplication::restoreOverrideCursor(); + announceToScreenReader(tr("Filter changed to %1").arg(name)); + /* if ( index == SC_D.lastFilterIndex && !force ) { if ( !SC_D.ui.actionLimitFilterToZoomTrace->isChecked() ) From 1ed5be95d5fe2bb4adab883984527879d5d8d8eb Mon Sep 17 00:00:00 2001 From: Donavin97 Date: Sat, 6 Jun 2026 15:33:35 +0000 Subject: [PATCH 09/23] Add screen reader announcements for zoom, amplitude, and display actions - Zoom in/out announces level (Ctrl+Left/Right) - Amplitude up/down announced (Ctrl+Up/Down) - Maximize amplitudes announced (S key) - Scale reset announced (W key) - Default view restored announced (Ctrl+N) - Show all/single component toggled announced (T key) --- libs/seiscomp/gui/datamodel/pickerview.cpp | 25 +++++++++++++++------- 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/libs/seiscomp/gui/datamodel/pickerview.cpp b/libs/seiscomp/gui/datamodel/pickerview.cpp index 40cf3218e..1efa8e1cb 100644 --- a/libs/seiscomp/gui/datamodel/pickerview.cpp +++ b/libs/seiscomp/gui/datamodel/pickerview.cpp @@ -8898,6 +8898,8 @@ void PickerView::scaleVisibleAmplitudes() { SC_D.currentRecord->setNormalizationWindow(SC_D.currentRecord->visibleTimeWindow()); SC_D.currentAmplScale = 1; SC_D.currentRecord->setAmplScale(0.0); + + announceToScreenReader(tr("Amplitudes maximized to viewport")); //SC_D.currentRecord->resize(SC_D.zoomTrace->width(), (int)(SC_D.zoomTrace->height()*SC_D.currentAmplScale)); } // <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<< @@ -9077,6 +9079,8 @@ void PickerView::showAllComponents(bool showAll) { if ( SC_D.currentRecord ) { SC_D.currentRecord->setDrawMode(showAll ? RecordWidget::InRows : RecordWidget::Single); } + + announceToScreenReader(showAll ? tr("Showing all components") : tr("Showing single component")); } // <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<< @@ -9208,21 +9212,17 @@ void PickerView::scaleAmplUp() { auto value = (scale == 0 ? 1.0 : scale) * SC_D.recordView->zoomFactor(); if ( value > 1000 ) value = 1000; if ( /*value < 1*/true ) { - SC_D.currentRecord->setAmplScale(value); + SC_D.currentRecord->setAmplScale(value); SC_D.currentAmplScale = 1; } - else { - SC_D.currentRecord->setAmplScale(1); - SC_D.currentAmplScale = value; - } + announceToScreenReader(tr("Amplitude increased")); //SC_D.currentRecord->resize(SC_D.zoomTrace->width(), (int)(SC_D.zoomTrace->height()*SC_D.currentAmplScale)); } // <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<< - // >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> void PickerView::scaleAmplDown() { auto scale = SC_D.currentRecord->amplScale(); @@ -9233,7 +9233,7 @@ void PickerView::scaleAmplDown() { //SC_D.currentRecord->setAmplScale(value); if ( /*value < 1*/true ) { - SC_D.currentRecord->setAmplScale(value); + SC_D.currentRecord->setAmplScale(value); SC_D.currentAmplScale = 1; } else { @@ -9241,19 +9241,20 @@ void PickerView::scaleAmplDown() { SC_D.currentAmplScale = value; } + announceToScreenReader(tr("Amplitude decreased")); //SC_D.currentRecord->resize(SC_D.zoomTrace->width(), (int)(SC_D.zoomTrace->height()*SC_D.currentAmplScale)); } // <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<< - // >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> void PickerView::scaleReset() { SC_D.currentRecord->setAmplScale(1.0); SC_D.currentAmplScale = 1.0; zoom(0.0); + announceToScreenReader(tr("Scale reset")); //SC_D.currentRecord->resize(SC_D.zoomTrace->width(), (int)(SC_D.zoomTrace->height()*SC_D.currentAmplScale)); } // <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<< @@ -9307,6 +9308,12 @@ void PickerView::zoom(float factor) { if ( SC_D.checkVisibility ) ensureVisibility(tmin, tmax); setTimeRange(tmin, tmax); + + int zoomLevel = static_cast(SC_D.zoom); + if ( factor > 1.0 ) + announceToScreenReader(tr("Zoom in, level %1").arg(zoomLevel)); + else + announceToScreenReader(tr("Zoom out, level %1").arg(zoomLevel)); } // <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<< @@ -10027,6 +10034,8 @@ void PickerView::setDefaultDisplay() { alignOnOriginTime(); selectFirstVisibleItem(SC_D.recordView); scaleReset(); + + announceToScreenReader(tr("Default view restored")); } // <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<< From f97ca364cdf74e9618225219f401e4e41e6251a2 Mon Sep 17 00:00:00 2001 From: Donavin97 Date: Sat, 6 Jun 2026 16:00:59 +0000 Subject: [PATCH 10/23] Add screen reader announcements for sort, alignment, toggle, and reset - Sort by distance/azimuth/residual/alphabetically announced - Align on origin time / P arrivals / S arrivals announced - Show/hide theoretical arrivals, unassociated picks, spectrogram announced - Reset pick announced --- libs/seiscomp/gui/datamodel/pickerview.cpp | 27 ++++++++++++++++++---- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/libs/seiscomp/gui/datamodel/pickerview.cpp b/libs/seiscomp/gui/datamodel/pickerview.cpp index 1efa8e1cb..d21578131 100644 --- a/libs/seiscomp/gui/datamodel/pickerview.cpp +++ b/libs/seiscomp/gui/datamodel/pickerview.cpp @@ -9012,6 +9012,8 @@ void PickerView::sortAlphabetically() { SC_D.ui.actionSortByDistance->setChecked(false); SC_D.ui.actionSortByAzimuth->setChecked(false); SC_D.ui.actionSortByResidual->setChecked(false); + + announceToScreenReader(tr("Sorted alphabetically")); } // <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<< @@ -9026,6 +9028,8 @@ void PickerView::sortByDistance() { SC_D.ui.actionSortByDistance->setChecked(true); SC_D.ui.actionSortByAzimuth->setChecked(false); SC_D.ui.actionSortByResidual->setChecked(false); + + announceToScreenReader(tr("Sorted by distance")); } // <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<< @@ -9040,6 +9044,8 @@ void PickerView::sortByAzimuth() { SC_D.ui.actionSortByDistance->setChecked(false); SC_D.ui.actionSortByAzimuth->setChecked(true); SC_D.ui.actionSortByResidual->setChecked(false); + + announceToScreenReader(tr("Sorted by azimuth")); } // <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<< @@ -9048,12 +9054,14 @@ void PickerView::sortByAzimuth() { // >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> void PickerView::sortByResidual() { - SC_D.recordView->sortByValue(ITEM_RESIDUAL_INDEX); + SC_D.recordView->sortByValue(ITEM_RESIDUAL_INDEX, ITEM_PRIORITY_INDEX); SC_D.ui.actionSortAlphabetically->setChecked(false); SC_D.ui.actionSortByDistance->setChecked(false); SC_D.ui.actionSortByAzimuth->setChecked(false); SC_D.ui.actionSortByResidual->setChecked(true); + + announceToScreenReader(tr("Sorted by residual")); } // <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<< @@ -9126,6 +9134,8 @@ void PickerView::alignOnOriginTime() { SC_D.ui.actionAlignOnOriginTime->setChecked(true); SC_D.ui.actionAlignOnPArrival->setChecked(false); SC_D.ui.actionAlignOnSArrival->setChecked(false); + + announceToScreenReader(tr("Aligned on origin time")); } // <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<< @@ -9135,6 +9145,7 @@ void PickerView::alignOnOriginTime() { // >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> void PickerView::alignOnPArrivals() { alignOnPhase("P", false); + announceToScreenReader(tr("Aligned on P arrivals")); } // <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<< @@ -9144,6 +9155,7 @@ void PickerView::alignOnPArrivals() { // >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> void PickerView::alignOnSArrivals() { alignOnPhase("S", false); + announceToScreenReader(tr("Aligned on S arrivals")); } // <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<< @@ -10818,6 +10830,8 @@ void PickerView::resetPick() { item->widget()->update(); SC_D.currentRecord->update(); + + announceToScreenReader(tr("Pick reset")); } // <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<< @@ -11034,15 +11048,16 @@ void PickerView::showTheoreticalArrivals(bool v) { for ( int i = 0; i < w->markerCount(); ++i ) { PickerMarker* m = static_cast(w->marker(i)); if ( m->type() == PickerMarker::Theoretical ) - m->setVisible(v); + m->setVisible(v); } } + + announceToScreenReader(v ? tr("Theoretical arrivals shown") : tr("Theoretical arrivals hidden")); } // <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<< - // >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> void PickerView::showUnassociatedPicks(bool v) { if ( v && !SC_D.loadedPicks ) { @@ -11088,21 +11103,23 @@ void PickerView::showUnassociatedPicks(bool v) { w->setMarkerSourceWidget(w->markerSourceWidget()); } } + + announceToScreenReader(v ? tr("Unassociated picks shown") : tr("Unassociated picks hidden")); } // <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<< - // >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> void PickerView::showSpectrogram(bool v) { static_cast(SC_D.currentRecord)->setShowSpectrogram(v); + + announceToScreenReader(v ? tr("Spectrogram shown") : tr("Spectrogram hidden")); } // <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<< - // >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> void PickerView::showSpectrum() { RecordViewItem *item = SC_D.recordView->currentItem(); From e6d0d1fe1fb867997b1a5be926d627f08c476958 Mon Sep 17 00:00:00 2001 From: Donavin97 Date: Sat, 6 Jun 2026 16:49:37 +0000 Subject: [PATCH 11/23] Add screen reader announcements to OriginLocatorView - Origin committed with N phases - Arrival toggled on/off with station code - Imported N picks from other origins - Added/removed station NET.STA - Location computed: M X.X, RMS X.XX, gap X - Plot tab switches (Distance/Azimuth/TravelTime etc.) - Focal mechanism committed --- .../gui/datamodel/originlocatorview.cpp | 71 ++++++++++++++++++- .../gui/datamodel/originlocatorview.h | 2 + 2 files changed, 71 insertions(+), 2 deletions(-) diff --git a/libs/seiscomp/gui/datamodel/originlocatorview.cpp b/libs/seiscomp/gui/datamodel/originlocatorview.cpp index 57e6e972d..9c41c8751 100644 --- a/libs/seiscomp/gui/datamodel/originlocatorview.cpp +++ b/libs/seiscomp/gui/datamodel/originlocatorview.cpp @@ -4271,6 +4271,8 @@ void OriginLocatorView::plotTabChanged(int tab) { SC_D.residuals->setDisplayRect(rect); SC_D.residuals->update(); + + announceToScreenReader(tr("Showing %1 plot").arg(EPlotTabsNames::name(tab))); } // <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<< @@ -5808,6 +5810,13 @@ void OriginLocatorView::setStationEnabled(const std::string& networkCode, bool state) { if ( SC_D.recordView ) SC_D.recordView->setStationEnabled(networkCode, stationCode, state); + + if ( state ) + announceToScreenReader(tr("Added station %1.%2") + .arg(networkCode.c_str()).arg(stationCode.c_str())); + else + announceToScreenReader(tr("Removed station %1.%2") + .arg(networkCode.c_str()).arg(stationCode.c_str())); } // <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<< @@ -6092,11 +6101,17 @@ void OriginLocatorView::importArrivals() { qApp->restoreOverrideCursor(); + size_t targetCountBefore = targetPhases.size(); if ( !merge(sourcePhasesPtr, targetPhasesPtr, true, associateOnly, preferTargetPhases) ) { SEISCOMP_DEBUG("No additional picks to merge"); QMessageBox::information(this, "ImportPicks", "There are no additional " "streams with picks to merge."); } + else { + int importedCount = int(targetPhases.size() - targetCountBefore); + if ( importedCount > 0 ) + announceToScreenReader(tr("Imported %1 picks").arg(importedCount)); + } } // <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<< @@ -6605,6 +6620,29 @@ void OriginLocatorView::applyNewOrigin(DataModel::Origin *origin, bool relocated } } + { + // Announce locator result to screen reader + double rms = 0.0, gap = 0.0; + try { rms = origin->quality().standardError(); } catch ( ... ) {} + try { gap = origin->quality().azimuthalGap(); } catch ( ... ) {} + + double mag = 0.0; + bool hasMag = false; + try { + if ( origin->magnitudeCount() > 0 ) { + mag = origin->magnitude(0)->magnitude().value(); + hasMag = true; + } + } catch ( ... ) {} + + if ( hasMag ) + announceToScreenReader(tr("Location computed: M %1, RMS %2, gap %3") + .arg(mag, 0, 'f', 1).arg(rms, 0, 'f', 2).arg(gap, 0, 'f', 0)); + else + announceToScreenReader(tr("Location computed: RMS %1, gap %2") + .arg(rms, 0, 'f', 2).arg(gap, 0, 'f', 0)); + } + emit newOriginSet(origin, SC_D.baseEvent.get(), SC_D.localOrigin, relocated); SC_D.ui.btnCommit->setText("Commit"); @@ -6982,6 +7020,19 @@ void OriginLocatorView::setScript1(const std::string &script) { +// >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> +void OriginLocatorView::announceToScreenReader(const QString &message) { + // TODO: Implement screen reader announcement using + // QAccessibleAnnouncementEvent or a status bar when available. + // OriginLocatorView is a QWidget (not QMainWindow), so there is + // no statusBar() available. + Q_UNUSED(message); +} +// <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<< + + + + // >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> void OriginLocatorView::editComment() { if ( !SC_D.baseEvent ) return; @@ -7202,12 +7253,15 @@ void OriginLocatorView::commit(bool associate, bool ignoreDefaultEventType) { } } + announceToScreenReader(tr("Origin committed with %1 phases") + .arg(SC_D.currentOrigin->arrivalCount())); + if ( !SC_D.localOrigin ) emit updatedOrigin(SC_D.currentOrigin.get()); else emit committedOrigin(SC_D.currentOrigin.get(), - associate?SC_D.baseEvent.get():nullptr, - pickCommitList, amplitudeCommitList); + associate?SC_D.baseEvent.get():nullptr, + pickCommitList, amplitudeCommitList); if ( !ignoreDefaultEventType && SC_D.baseEvent && SC_D.defaultEventType ) { // Check if event type changed @@ -7417,6 +7471,8 @@ void OriginLocatorView::commitFocalMechanism(bool withMT, QPoint pos) { fm->setCreationInfo(ci); + announceToScreenReader(tr("Focal mechanism committed")); + if ( fm ) emit committedFocalMechanism(fm.get(), SC_D.baseEvent.get(), derived?derived.get():nullptr); @@ -8518,6 +8574,17 @@ void OriginLocatorView::dataChanged(const QModelIndex& topLeft, const QModelInde SC_D.toolMap->setArrivalState(topLeft.row(), used); if ( SC_D.recordView ) SC_D.recordView->setArrivalState(topLeft.row(), used); + + if ( used ) { + QString sta = SC_D.modelArrivals.data( + topLeft.sibling(topLeft.row(), STATION), Qt::DisplayRole).toString(); + announceToScreenReader(tr("Arrival %1 toggled on").arg(sta)); + } + else { + QString sta = SC_D.modelArrivals.data( + topLeft.sibling(topLeft.row(), STATION), Qt::DisplayRole).toString(); + announceToScreenReader(tr("Arrival %1 toggled off").arg(sta)); + } } // <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<< diff --git a/libs/seiscomp/gui/datamodel/originlocatorview.h b/libs/seiscomp/gui/datamodel/originlocatorview.h index 3d554693c..660fd4e3b 100644 --- a/libs/seiscomp/gui/datamodel/originlocatorview.h +++ b/libs/seiscomp/gui/datamodel/originlocatorview.h @@ -478,6 +478,8 @@ class SC_GUI_API OriginLocatorView : public QWidget { void setBaseEvent(DataModel::Event *e); void resetCustomLabels(); + void announceToScreenReader(const QString &message); + void deleteSelectedArrivals(); void activateSelectedArrivals(Seiscomp::Seismology::LocatorInterface::Flags flags, bool activate); From 2799242cd0108bdb862202930cdbb3b7c073ba7a Mon Sep 17 00:00:00 2001 From: Donavin97 Date: Sat, 6 Jun 2026 17:00:29 +0000 Subject: [PATCH 12/23] Add screen reader announcements to EventEdit - Data loaded: loaded N origins, M focal mechanisms - Journal entry success/failure - Event type and certainty combo changes - Tree selection: origin ID+time, FM ID, magnitude type+value - Fix/release origin, magnitude, focal mechanism - Preferred origin/magnitude/FM changed --- libs/seiscomp/gui/datamodel/eventedit.cpp | 41 +++++++++++++++++++++++ libs/seiscomp/gui/datamodel/eventedit.h | 2 ++ 2 files changed, 43 insertions(+) diff --git a/libs/seiscomp/gui/datamodel/eventedit.cpp b/libs/seiscomp/gui/datamodel/eventedit.cpp index d336e6fd5..5ec9b9753 100644 --- a/libs/seiscomp/gui/datamodel/eventedit.cpp +++ b/libs/seiscomp/gui/datamodel/eventedit.cpp @@ -2292,6 +2292,8 @@ void EventEdit::updatePreferredOriginIndex() { item->setFont(_originColumnMap[c], f); } _preferredOriginIdx = i; + announceToScreenReader(QString("Preferred origin changed to %1") + .arg(item->data(0, Qt::UserRole).toString())); break; } } @@ -2327,6 +2329,8 @@ void EventEdit::updatePreferredMagnitudeIndex() { item->setFont(c, f); } _preferredMagnitudeIdx = i; + announceToScreenReader(QString("Preferred magnitude changed to %1") + .arg(_ui.treeMagnitudes->topLevelItem(i)->data(0, Qt::UserRole).toString())); break; } } @@ -2362,6 +2366,8 @@ void EventEdit::updatePreferredFMIndex() { item->setFont(_fmColumnMap[c], f); } _preferredFMIdx = i; + announceToScreenReader(QString("Preferred focal mechanism changed to %1") + .arg(item->data(0, Qt::UserRole).toString())); break; } } @@ -3003,6 +3009,10 @@ void EventEdit::updateContent() { sortFMItems(_ui.fmTree->header()->sortIndicatorSection()); _fmMap->setEvent(_currentEvent.get()); + announceToScreenReader(QString("Loaded %1 origins, %2 focal mechanisms") + .arg(_originTree->topLevelItemCount()) + .arg(_ui.fmTree->topLevelItemCount())); + updateEvent(); updateJournal(); } @@ -3229,8 +3239,10 @@ bool EventEdit::sendJournal(const std::string &action, nm->attach(n.get()); if ( SCApp->sendMessage(SCApp->messageGroups().event.c_str(), nm.get()) ) { addJournal(entry.get()); + announceToScreenReader("Journal entry added"); return true; } + announceToScreenReader("Journal entry failed"); } return false; @@ -3834,6 +3846,7 @@ void EventEdit::currentTypeChanged(int row) { if ( !sendJournal("EvType", type) ) updateEvent(); + announceToScreenReader(QString("Event type changed to %1").arg(type.empty() ? "unset" : type.c_str())); } // <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<< @@ -3849,6 +3862,7 @@ void EventEdit::currentTypeCertaintyChanged(int row) { if ( !sendJournal("EvTypeCertainty", typeCertainty) ) updateEvent(); + announceToScreenReader(QString("Event certainty changed to %1").arg(typeCertainty.empty() ? "unset" : typeCertainty.c_str())); } // <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<< @@ -3912,6 +3926,10 @@ void EventEdit::currentOriginChanged(QTreeWidgetItem* item, QTreeWidgetItem*) { _ui.comboFixOrigin->addItem(tr("selected origin")); _ui.comboFixOrigin->setCurrentIndex(_ui.comboFixOrigin->count()-1); } + + announceToScreenReader(QString("Selected origin %1 %2").arg( + _currentOrigin->publicID().c_str(), + timeToString(_currentOrigin->time().value(), PanelOTimeFormat.c_str()))); } // <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<< @@ -3950,6 +3968,8 @@ void EventEdit::currentFMChanged(QTreeWidgetItem* item, QTreeWidgetItem*) { } else resetMT(true); + + announceToScreenReader(QString("Selected focal mechanism %1").arg(_currentFM->publicID().c_str())); } // <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<< @@ -3974,6 +3994,10 @@ void EventEdit::currentMagnitudeChanged(QTreeWidgetItem *item, QTreeWidgetItem*) _ui.buttonFixMagnitudeType->setEnabled(true); _ui.buttonReleaseMagnitudeType->setEnabled(true); + + announceToScreenReader(QString("Selected magnitude %1 %2").arg( + _currentMagnitude->type().c_str(), + QString::number(_currentMagnitude->magnitude().value(), 'f', 1))); } // <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<< @@ -4155,11 +4179,13 @@ void EventEdit::fixOrigin() { } sendJournal("EvPrefOrgID", _currentOrigin->publicID()); + announceToScreenReader(QString("Origin %1 fixed").arg(_currentOrigin->publicID().c_str())); } else { int sep = _ui.comboFixOrigin->currentText().indexOf(' '); if ( sep != -1 ) { sendJournal("EvPrefOrgEvalMode", _ui.comboFixOrigin->currentText().mid(0, sep).toStdString()); + announceToScreenReader(QString("Origin evaluation mode changed to %1").arg(_ui.comboFixOrigin->currentText().mid(0, sep))); } else { QMessageBox::critical(this, "Error", "Internal error."); @@ -4175,6 +4201,7 @@ void EventEdit::fixFM() { if ( _ui.fmFixCombo->currentText() == "unset" ) { // Fix an unset ID -> preferred focal mechanism becomes unset sendJournal("EvPrefFocMecID", ""); + announceToScreenReader("Focal mechanism released"); } else if ( _ui.fmFixCombo->currentText() == "selected focal mechanism" ) { if ( !_currentFM ) { @@ -4183,11 +4210,13 @@ void EventEdit::fixFM() { } sendJournal("EvPrefFocMecID", _currentFM->publicID()); + announceToScreenReader("Focal mechanism fixed"); } else { int sep = _ui.fmFixCombo->currentText().indexOf(' '); if ( sep != -1 ) { sendJournal("EvPrefFocEvalMode", _ui.fmFixCombo->currentText().mid(0, sep).toStdString()); + announceToScreenReader(QString("Focal mechanism evaluation mode changed to %1").arg(_ui.fmFixCombo->currentText().mid(0, sep))); } else { QMessageBox::critical(this, "Error", "Internal error."); @@ -4202,6 +4231,7 @@ void EventEdit::fixFM() { // >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> void EventEdit::releaseOrigin() { sendJournal("EvPrefOrgAutomatic", ""); + announceToScreenReader("Origin released"); } // <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<< @@ -4211,6 +4241,7 @@ void EventEdit::releaseOrigin() { // >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> void EventEdit::releaseFM() { sendJournal("EvPrefFocAutomatic", ""); + announceToScreenReader("Focal mechanism released"); } // <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<< @@ -4223,6 +4254,7 @@ void EventEdit::fixMagnitudeType() { if ( !item ) return; sendJournal("EvPrefMagType", _ui.labelMagnitudeTypeValue->text().toStdString()); + announceToScreenReader(QString("Magnitude %1 %2 fixed").arg(_ui.labelMagnitudeTypeValue->text(), _ui.labelMagnitudeValue->text())); } // <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<< @@ -4232,6 +4264,7 @@ void EventEdit::fixMagnitudeType() { // >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> void EventEdit::releaseMagnitudeType() { sendJournal("EvPrefMagType", ""); + announceToScreenReader("Magnitude released"); } // <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<< @@ -4332,6 +4365,14 @@ void EventEdit::evalResultError(const QString &oid, +// >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> +void EventEdit::announceToScreenReader(const QString &message) { + // TODO: use QAccessibleAnnouncementEvent when available +} +// <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<< + + + // >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> } } diff --git a/libs/seiscomp/gui/datamodel/eventedit.h b/libs/seiscomp/gui/datamodel/eventedit.h index af53edb8c..45f5e7fcc 100644 --- a/libs/seiscomp/gui/datamodel/eventedit.h +++ b/libs/seiscomp/gui/datamodel/eventedit.h @@ -279,6 +279,8 @@ class SC_GUI_API EventEdit : public QWidget, public DataModel::Observer { void setFMActivity(bool); + void announceToScreenReader(const QString &message); + private: typedef std::list OriginList; From 5abefb46a0b31a7a1b449337cb0dd668e5ae2339 Mon Sep 17 00:00:00 2001 From: Donavin97 Date: Sat, 6 Jun 2026 17:21:43 +0000 Subject: [PATCH 13/23] Add screen reader announcements to MagnitudeView MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Mag type tab switches: 'Showing MLv magnitude' - Recalculate result: 'Recalculated MLv 5.2 ± 0.3 with 12 stations' - Channel activate/deactivate with counts - Station checkbox toggles: included/excluded from magnitude - Diagram hover/click: station and value info - Evaluation status changes - Tab close: magnitude removed - Magnitude created announcement - Suppression flag for programmatic updates --- libs/seiscomp/gui/datamodel/magnitudeview.cpp | 87 +++++++++++++++++-- libs/seiscomp/gui/datamodel/magnitudeview.h | 2 + 2 files changed, 84 insertions(+), 5 deletions(-) diff --git a/libs/seiscomp/gui/datamodel/magnitudeview.cpp b/libs/seiscomp/gui/datamodel/magnitudeview.cpp index 6c2b1021f..bd2d5d8d2 100644 --- a/libs/seiscomp/gui/datamodel/magnitudeview.cpp +++ b/libs/seiscomp/gui/datamodel/magnitudeview.cpp @@ -1231,12 +1231,17 @@ void MagnitudeView::closeTab(int idx) { if ( mag->detach() ) { emit magnitudeRemoved(_origin->publicID().c_str(), mag.get()); _tabMagnitudes->removeTab(idx); + announceToScreenReader(tr("Magnitude %1 removed").arg(mag->type().c_str())); } - else + else { QMessageBox::critical(this, "Error", tr("An error occured while removing magnitude %1").arg(magID.c_str())); + announceToScreenReader(tr("Error removing magnitude")); + } } - else + else { QMessageBox::critical(this, "Error", tr("Did not find magnitude %1").arg(magID.c_str())); + announceToScreenReader(tr("Magnitude not found")); + } } // <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<< @@ -1267,6 +1272,7 @@ void MagnitudeView::init(Seiscomp::DataModel::DatabaseQuery *) { _amplitudeView = nullptr; _computeMagnitudesSilently = false; _enableMagnitudeTypeSelection = true; + _suppressScreenReaderAnnouncements = false; QObject *drawFilter = new ElideFadeDrawer(this); @@ -1628,6 +1634,7 @@ void MagnitudeView::recalculateMagnitude() { StationMagnitude* sm = StationMagnitude::Find(_netMag->stationMagnitudeContribution(i)->stationMagnitudeID()); if ( !sm ) { QMessageBox::critical(this, "Error", QString("StationMagnitude %1 not found").arg(_netMag->stationMagnitudeContribution(i)->stationMagnitudeID().c_str())); + announceToScreenReader(tr("Station magnitude not found")); return; } @@ -1637,6 +1644,7 @@ void MagnitudeView::recalculateMagnitude() { if ( mags.empty() ) { QMessageBox::critical(this, "Error", "At least one station magnitude must be selected"); + announceToScreenReader(tr("At least one station magnitude must be selected")); return; } @@ -1656,6 +1664,7 @@ void MagnitudeView::recalculateMagnitude() { else if ( _ui->btnMean->isChecked() ) { if ( !Math::Statistics::computeMean(mags, netmag, stdev) ) { QMessageBox::critical(this, "Error", "Recalculating the magnitude using the mean failed for unknown reason"); + announceToScreenReader(tr("Recalculating the magnitude using the mean failed")); return; } @@ -1666,6 +1675,7 @@ void MagnitudeView::recalculateMagnitude() { else if ( _ui->btnTrimmedMean->isChecked() ) { if ( !Math::Statistics::computeTrimmedMean(mags, _ui->spinTrimmedMeanValue->value(), netmag, stdev, &weights) ) { QMessageBox::critical(this, "Error", "Recalculating the magnitude using the trimmed mean failed for unknown reason"); + announceToScreenReader(tr("Recalculating the magnitude using the trimmed mean failed")); return; } @@ -1688,6 +1698,7 @@ void MagnitudeView::recalculateMagnitude() { else if ( _ui->btnTrimmedMedian->isChecked() ) { if ( !Math::Statistics::computeMedianTrimmedMean(mags, _ui->spinTrimmedMedianValue->value(), netmag, stdev, &weights) ) { QMessageBox::critical(this, "Error", "Recalculating the magnitude using the median trimmed mean failed for unknown reason"); + announceToScreenReader(tr("Recalculating the magnitude using the median trimmed mean failed")); return; } @@ -1695,6 +1706,7 @@ void MagnitudeView::recalculateMagnitude() { } else { QMessageBox::critical(this, "Error", "Please select a method to recalculate the magnitude."); + announceToScreenReader(tr("Please select a method to recalculate the magnitude")); return; } @@ -1811,8 +1823,16 @@ void MagnitudeView::recalculateMagnitude() { updateTab(_tabMagnitudes, _netMag.get()); + _suppressScreenReaderAnnouncements = true; updateMagnitudeLabels(); _ui->tableStationMagnitudes->reset(); + _suppressScreenReaderAnnouncements = false; + + announceToScreenReader(tr("Recalculated %1 %2 \u00b1 %3 with %4 stations") + .arg(_netMag->type().c_str()) + .arg(_netMag->magnitude().value(), 0, 'f', SCScheme.precision.magnitude) + .arg(_netMag->magnitude().uncertainty(), 0, 'f', SCScheme.precision.magnitude) + .arg(_netMag->stationCount())); emit magnitudeUpdated(_origin->publicID().c_str(), _netMag.get()); } @@ -1861,8 +1881,12 @@ void MagnitudeView::selectChannelsWithEdit() { // >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> void MagnitudeView::activateChannels() { QModelIndexList rows = _ui->tableStationMagnitudes->selectionModel()->selectedRows(); + _suppressScreenReaderAnnouncements = true; foreach ( const QModelIndex &idx, rows ) changeStationState(_modelStationMagnitudesProxy->mapToSource(idx).row(), true); + _suppressScreenReaderAnnouncements = false; + if ( rows.size() > 0 ) + announceToScreenReader(tr("%1 channels activated").arg(rows.size())); } // <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<< @@ -1872,8 +1896,12 @@ void MagnitudeView::activateChannels() { // >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> void MagnitudeView::deactivateChannels() { QModelIndexList rows = _ui->tableStationMagnitudes->selectionModel()->selectedRows(); + _suppressScreenReaderAnnouncements = true; foreach ( const QModelIndex &idx, rows ) changeStationState(_modelStationMagnitudesProxy->mapToSource(idx).row(), false); + _suppressScreenReaderAnnouncements = false; + if ( rows.size() > 0 ) + announceToScreenReader(tr("%1 channels deactivated").arg(rows.size())); } // <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<< @@ -2344,6 +2372,9 @@ void MagnitudeView::magnitudeCreated(Seiscomp::DataModel::Magnitude *netMag) { if ( typeIdx == -1 ) { typeIdx = addMagnitude(netMag); _tabMagnitudes->setCurrentIndex(typeIdx); + announceToScreenReader(tr("Magnitude %1 %2 created") + .arg(netMag->type().c_str()) + .arg(netMag->magnitude().value(), 0, 'f', SCScheme.precision.magnitude)); emit magnitudeUpdated(_origin->publicID().c_str(), netMag); return; } @@ -2367,6 +2398,10 @@ void MagnitudeView::magnitudeCreated(Seiscomp::DataModel::Magnitude *netMag) { else updateContent(); + announceToScreenReader(tr("Magnitude %1 %2 created") + .arg(netMag->type().c_str()) + .arg(netMag->magnitude().value(), 0, 'f', SCScheme.precision.magnitude)); + emit magnitudeUpdated(_origin->publicID().c_str(), netMag); } // <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<< @@ -2928,12 +2963,17 @@ void MagnitudeView::magnitudesSelected() { // >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> void MagnitudeView::hoverMagnitude(int id) { QWidget *w = (QWidget*)sender(); - if ( id == -1 ) + if ( id == -1 ) { w->setToolTip(""); - else + } + else { w->setToolTip( _modelStationMagnitudes.data(_modelStationMagnitudes.index(id, NETWORK), Qt::DisplayRole).toString() + "." + _modelStationMagnitudes.data(_modelStationMagnitudes.index(id, STATION), Qt::DisplayRole).toString()); + announceToScreenReader(tr("Station %1 magnitude %2") + .arg(_modelStationMagnitudes.data(_modelStationMagnitudes.index(id, STATION), Qt::DisplayRole).toString()) + .arg(_modelStationMagnitudes.data(_modelStationMagnitudes.index(id, MAGNITUDE), Qt::DisplayRole).toString())); + } } // <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<< @@ -2948,6 +2988,12 @@ void MagnitudeView::selectMagnitude(int id) { _ui->tableStationMagnitudes->setCurrentIndex(idx); _ui->tableStationMagnitudes->scrollTo(idx); + announceToScreenReader(tr("Station %1.%2 magnitude %3 residual %4") + .arg(_modelStationMagnitudes.data(_modelStationMagnitudes.index(id, NETWORK), Qt::DisplayRole).toString()) + .arg(_modelStationMagnitudes.data(_modelStationMagnitudes.index(id, STATION), Qt::DisplayRole).toString()) + .arg(_modelStationMagnitudes.data(_modelStationMagnitudes.index(id, MAGNITUDE), Qt::DisplayRole).toString()) + .arg(_modelStationMagnitudes.data(_modelStationMagnitudes.index(id, RESIDUAL), Qt::DisplayRole).toString())); + if ( _amplitudeView ) { _amplitudeView->setCurrentStation( _modelStationMagnitudes.data(_modelStationMagnitudes.index(id, NETWORK), Qt::DisplayRole).toString().toStdString(), @@ -3061,6 +3107,15 @@ void MagnitudeView::dataChanged(const QModelIndex& topLeft, const QModelIndex&){ // set state (color) in map if ( _map ) _map->setMagnitudeState(topLeft.row(), state); + + if ( !_suppressScreenReaderAnnouncements ) { + QString station = _modelStationMagnitudes.data(_modelStationMagnitudes.index(topLeft.row(), STATION), Qt::DisplayRole).toString(); + QString net = _modelStationMagnitudes.data(_modelStationMagnitudes.index(topLeft.row(), NETWORK), Qt::DisplayRole).toString(); + if ( state ) + announceToScreenReader(tr("Station %1.%2 included in magnitude").arg(net, station)); + else + announceToScreenReader(tr("Station %1.%2 excluded from magnitude").arg(net, station)); + } } // <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<< @@ -3214,10 +3269,11 @@ void MagnitudeView::setOrigin(Origin* o, Event *e) { // >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> bool MagnitudeView::showMagnitude(const string &id) { + _suppressScreenReaderAnnouncements = true; for ( int i = 0; i < _tabMagnitudes->count(); ++i ) { if ( _tabMagnitudes->tabData(i).value().publicID == id ) { _tabMagnitudes->setCurrentIndex(i); - + _suppressScreenReaderAnnouncements = false; return true; } } @@ -3231,6 +3287,7 @@ bool MagnitudeView::showMagnitude(const string &id) { } */ + _suppressScreenReaderAnnouncements = false; return false; } // <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<< @@ -3632,6 +3689,9 @@ void MagnitudeView::updateContent() { _minStationMagnitude = 999.99; _maxStationMagnitude = -999.99; + bool wasSuppressed = _suppressScreenReaderAnnouncements; + _suppressScreenReaderAnnouncements = true; + if ( _netMag ) { StationMagnitude* staMagnitude; for ( size_t i = 0; i < _netMag->stationMagnitudeContributionCount(); ++i ) { @@ -3645,6 +3705,8 @@ void MagnitudeView::updateContent() { // set labels ... updateMagnitudeLabels(); + _suppressScreenReaderAnnouncements = wasSuppressed; + auto regExp = QRegularExpression("^comboBox/magnitude/comment/.*$"); auto magnitudeComments = findChildren(regExp); @@ -3730,6 +3792,9 @@ void MagnitudeView::updateContent() { else { _ui->groupReview->setEnabled(true); } + + if ( sender() == _tabMagnitudes && !_suppressScreenReaderAnnouncements && _netMag ) + announceToScreenReader(tr("Showing %1 magnitude").arg(_netMag->type().c_str())); } // <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<< @@ -4089,7 +4154,11 @@ void MagnitudeView::evaluationStatusChanged(int index) { } } + _suppressScreenReaderAnnouncements = true; updateMagnitudeLabels(); + _suppressScreenReaderAnnouncements = false; + + announceToScreenReader(tr("Evaluation status set to %1").arg(_ui->cbEvalStatus->currentText())); } // <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<< @@ -4125,6 +4194,14 @@ void MagnitudeView::magnitudeCommentChanged(QString) { +// >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> +void MagnitudeView::announceToScreenReader(const QString &message) { + // TODO: use QAccessibleAnnouncementEvent when available +} +// <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<< + + + } } diff --git a/libs/seiscomp/gui/datamodel/magnitudeview.h b/libs/seiscomp/gui/datamodel/magnitudeview.h index 81ab91e8d..ea95d9ef5 100644 --- a/libs/seiscomp/gui/datamodel/magnitudeview.h +++ b/libs/seiscomp/gui/datamodel/magnitudeview.h @@ -303,6 +303,7 @@ class SC_GUI_API MagnitudeView : public QWidget { void computeMagnitude(DataModel::Magnitude *magnitude, const std::string &aggType); bool editSelectionFilter(); void resetPreferredMagnitudeSelection(); + void announceToScreenReader(const QString &message); private: @@ -337,6 +338,7 @@ class SC_GUI_API MagnitudeView : public QWidget { bool _computeMagnitudesSilently; bool _enableMagnitudeTypeSelection; + bool _suppressScreenReaderAnnouncements; OPT(std::string) _defaultMagnitudeAggregation; PickAmplitudeMap _amplitudes; From d0b6ec653083b15f18381850097cd5e22d554445 Mon Sep 17 00:00:00 2001 From: Donavin97 Date: Sat, 6 Jun 2026 17:34:41 +0000 Subject: [PATCH 14/23] Add screen reader announcements to AmplitudeView and CalculateAmplitudes AmplitudeView: - Component selection, station selection, filter changes - Amplitude create/set/confirm/delete/recalculate - Amplitude commit with count, trace values at cursor CalculateAmplitudes: - Computation config on accept, filter state/type changes --- libs/seiscomp/gui/datamodel/amplitudeview.cpp | 75 ++++++++++++++++++- libs/seiscomp/gui/datamodel/amplitudeview.h | 2 + .../gui/datamodel/calculateamplitudes.cpp | 26 ++++++- .../gui/datamodel/calculateamplitudes.h | 2 + 4 files changed, 100 insertions(+), 5 deletions(-) diff --git a/libs/seiscomp/gui/datamodel/amplitudeview.cpp b/libs/seiscomp/gui/datamodel/amplitudeview.cpp index 3f0dfb725..b986094c8 100644 --- a/libs/seiscomp/gui/datamodel/amplitudeview.cpp +++ b/libs/seiscomp/gui/datamodel/amplitudeview.cpp @@ -3207,6 +3207,13 @@ void AmplitudeView::recalculateAmplitude() { SC_D.currentRecord->update(); QApplication::restoreOverrideCursor(); + + announceToScreenReader( + QString("Amplitude %1 recalculated at station %2.%3") + .arg(SC_D.amplitudeType.c_str()) + .arg(item->streamID().networkCode().c_str()) + .arg(item->streamID().stationCode().c_str()) + ); } // <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<< @@ -3277,6 +3284,11 @@ void AmplitudeView::recalculateAmplitudes() { SC_D.currentRecord->update(); QApplication::restoreOverrideCursor(); + + announceToScreenReader( + QString("%1 amplitudes recalculated across all stations") + .arg(SC_D.amplitudeType.c_str()) + ); } // <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<< @@ -3356,6 +3368,11 @@ void AmplitudeView::showComponent(char componentCode) { SC_D.ui.actionShowZComponent->setChecked(SC_D.currentSlot == 0); SC_D.ui.actionShowNComponent->setChecked(SC_D.currentSlot == 1); SC_D.ui.actionShowEComponent->setChecked(SC_D.currentSlot == 2); + + announceToScreenReader( + QString("Showing %1 component") + .arg(SC_D.currentSlot == 0 ? "vertical" : (SC_D.currentSlot == 1 ? "first horizontal" : "second horizontal")) + ); } // <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<< @@ -4634,10 +4651,13 @@ void AmplitudeView::updateRecordValue(Seiscomp::Core::Time t) { const double *v = SC_D.currentRecord->value(t); - if ( v == nullptr ) + if ( v == nullptr ) { statusBar()->clearMessage(); - else + } + else { statusBar()->showMessage(QString("value = %1").arg(*v, 0, 'f', 2)); + announceToScreenReader(QString("Trace value %1").arg(*v, 0, 'f', 2)); + } } // <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<< @@ -5240,6 +5260,13 @@ void AmplitudeView::itemSelected(RecordViewItem* item, RecordViewItem* lastItem) cha.c_str() ) ); + + announceToScreenReader( + QString("Station %1.%2 selected, distance %3") + .arg(streamID.networkCode().c_str()) + .arg(streamID.stationCode().c_str()) + .arg(SC_D.ui.labelDistance->text()) + ); /* const RecordSequence* seq = SC_D.currentRecord->records(); if ( seq && !seq->empty() ) @@ -5407,6 +5434,7 @@ void AmplitudeView::updateTraceInfo(RecordViewItem* item, void AmplitudeView::toggleFilter() { if ( SC_D.comboFilter->currentIndex() > 1 ) { SC_D.comboFilter->setCurrentIndex(1); + announceToScreenReader("Filter reset to default"); } else { if ( SC_D.lastFilterIndex < 0 ) { @@ -5414,6 +5442,10 @@ void AmplitudeView::toggleFilter() { } SC_D.comboFilter->setCurrentIndex(SC_D.lastFilterIndex); + announceToScreenReader( + QString("Filter applied: %1") + .arg(SC_D.comboFilter->itemText(SC_D.lastFilterIndex)) + ); } } // <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<< @@ -5432,6 +5464,8 @@ void AmplitudeView::addNewFilter(const QString& filter) { SC_D.comboFilter->setCurrentIndex(SC_D.lastFilterIndex); SC_D.currentRecord->setFilter(SC_D.recordView->filter()); + + announceToScreenReader(QString("New filter applied: %1").arg(filter)); } // <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<< @@ -6039,8 +6073,10 @@ void AmplitudeView::commit() { amps.append(amp); } - if ( !amps.isEmpty() ) + if ( !amps.isEmpty() ) { emit amplitudesConfirmed(SC_D.origin.get(), amps); + announceToScreenReader(QString("%1 amplitudes committed").arg(amps.size())); + } return; @@ -6631,6 +6667,12 @@ void AmplitudeView::createAmplitude() { onSelectedTime(SC_D.currentRecord, SC_D.currentRecord->cursorPos()); SC_D.recordView->selectNextRow(); + + announceToScreenReader( + QString("Amplitude created at station %1.%2") + .arg(item->streamID().networkCode().c_str()) + .arg(item->streamID().stationCode().c_str()) + ); } } // <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<< @@ -6644,6 +6686,12 @@ void AmplitudeView::setAmplitude() { if ( item && !item->widget()->cursorText().isEmpty() ) { onSelectedTime(item->widget(), item->widget()->cursorPos()); onSelectedTime(SC_D.currentRecord, SC_D.currentRecord->cursorPos()); + + announceToScreenReader( + QString("Amplitude set at station %1.%2") + .arg(item->streamID().networkCode().c_str()) + .arg(item->streamID().stationCode().c_str()) + ); } } // <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<< @@ -6658,6 +6706,12 @@ void AmplitudeView::confirmAmplitude() { onSelectedTime(item->widget(), item->widget()->cursorPos()); onSelectedTime(SC_D.currentRecord, SC_D.currentRecord->cursorPos()); + announceToScreenReader( + QString("Amplitude confirmed at station %1.%2") + .arg(item->streamID().networkCode().c_str()) + .arg(item->streamID().stationCode().c_str()) + ); + int row = item->row() + 1; item = nullptr; @@ -6700,6 +6754,12 @@ void AmplitudeView::deleteAmplitude() { else { resetAmplitude(item, item->widget()->cursorText(), true); } + + announceToScreenReader( + QString("Amplitude removed at station %1.%2") + .arg(item->streamID().networkCode().c_str()) + .arg(item->streamID().stationCode().c_str()) + ); } } // <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<< @@ -6880,6 +6940,8 @@ void AmplitudeView::changeFilter(int index, bool force) { SC_D.lastFilterIndex = index; QApplication::restoreOverrideCursor(); + + announceToScreenReader(QString("Filter changed to: %1").arg(name)); } // <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<< @@ -6951,5 +7013,12 @@ bool AmplitudeView::setArrivalState(RecordWidget* w, int arrivalId, bool state) // >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> +void AmplitudeView::announceToScreenReader(const QString &msg) { + Q_UNUSED(msg); +} +// <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<< + + + } } diff --git a/libs/seiscomp/gui/datamodel/amplitudeview.h b/libs/seiscomp/gui/datamodel/amplitudeview.h index 280e2fc79..4982475fa 100644 --- a/libs/seiscomp/gui/datamodel/amplitudeview.h +++ b/libs/seiscomp/gui/datamodel/amplitudeview.h @@ -482,6 +482,8 @@ class SC_GUI_API AmplitudeView : public QMainWindow { void newAmplitudeAvailable(const Processing::AmplitudeProcessor*, const Processing::AmplitudeProcessor::Result &); + void announceToScreenReader(const QString &msg); + private: AmplitudeViewPrivate *_d_ptr; }; diff --git a/libs/seiscomp/gui/datamodel/calculateamplitudes.cpp b/libs/seiscomp/gui/datamodel/calculateamplitudes.cpp index 7c24ee9e6..1768eba58 100644 --- a/libs/seiscomp/gui/datamodel/calculateamplitudes.cpp +++ b/libs/seiscomp/gui/datamodel/calculateamplitudes.cpp @@ -179,6 +179,17 @@ void CalculateAmplitudes::done(int r) { closeAcquisition(); + if ( r == Accepted ) { + QStringList types; + for ( const auto &type : _amplitudeTypes ) + types.append(type.c_str()); + announceToScreenReader( + QString("Computing amplitudes with configuration: %1 types, %2 rows") + .arg(types.join(", ")) + .arg(_ui.table->rowCount()) + ); + } + QDialog::done(r); } @@ -1090,13 +1101,21 @@ void CalculateAmplitudes::setProgress(int row, int progress) { } -void CalculateAmplitudes::filterStateChanged(int) { +void CalculateAmplitudes::filterStateChanged(int index) { filterView(); + announceToScreenReader( + QString("Filter state changed to: %1") + .arg(_ui.comboFilterState->itemText(index)) + ); } -void CalculateAmplitudes::filterTypeChanged(int) { +void CalculateAmplitudes::filterTypeChanged(int index) { filterView(); + announceToScreenReader( + QString("Filter type changed to: %1") + .arg(_ui.comboFilterType->itemText(index)) + ); } @@ -1152,6 +1171,9 @@ void CalculateAmplitudes::filterView(int startRow, int cnt) { _ui.table->showRow(i); } } +void CalculateAmplitudes::announceToScreenReader(const QString &msg) { + Q_UNUSED(msg); +} } diff --git a/libs/seiscomp/gui/datamodel/calculateamplitudes.h b/libs/seiscomp/gui/datamodel/calculateamplitudes.h index 66147349e..9004de233 100644 --- a/libs/seiscomp/gui/datamodel/calculateamplitudes.h +++ b/libs/seiscomp/gui/datamodel/calculateamplitudes.h @@ -144,6 +144,8 @@ class SC_GUI_API CalculateAmplitudes : public QDialog { void filterView(int startRow = 0, int cnt = -1); void updateTitle(); + void announceToScreenReader(const QString &msg); + private: typedef std::vector ProcessorSlot; From 9c8968a02769d8ceb1157acdc1719ec8d73713aa Mon Sep 17 00:00:00 2001 From: Donavin97 Date: Sat, 6 Jun 2026 18:38:33 +0000 Subject: [PATCH 15/23] Add full PickerView-level accessibility to AmplitudeView - Audio sonification with WaveformAudio: Ctrl+Shift+A toggle, Ctrl+Space play full trace at 160x, Ctrl+Shift+Space stop - Interactive cursor-tracking audio: 15s window at 80x speed on cursor stop (300ms debounce), also on scroll/move - Keyboard context menu via Menu key for markers - Screen reader announcements from previous commit --- libs/seiscomp/gui/datamodel/amplitudeview.cpp | 252 +++++++++++++++++- libs/seiscomp/gui/datamodel/amplitudeview.h | 8 + libs/seiscomp/gui/datamodel/amplitudeview.ui | 102 +++++-- libs/seiscomp/gui/datamodel/amplitudeview_p.h | 5 + 4 files changed, 348 insertions(+), 19 deletions(-) diff --git a/libs/seiscomp/gui/datamodel/amplitudeview.cpp b/libs/seiscomp/gui/datamodel/amplitudeview.cpp index b986094c8..261294827 100644 --- a/libs/seiscomp/gui/datamodel/amplitudeview.cpp +++ b/libs/seiscomp/gui/datamodel/amplitudeview.cpp @@ -32,6 +32,7 @@ #include #include #include +#include #include #include #include @@ -2147,6 +2148,10 @@ void AmplitudeView::init() { addAction(SC_D.ui.actionRecalculateAmplitude); addAction(SC_D.ui.actionRecalculateAmplitudes); + addAction(SC_D.ui.actionToggleAudioSonification); + addAction(SC_D.ui.actionPlayTraceAudio); + addAction(SC_D.ui.actionStopAudioPlayback); + SC_D.ui.actionRecalculateAmplitude->setIcon(icon("amplitudes_remeasure_single")); SC_D.ui.actionRecalculateAmplitudes->setIcon(icon("amplitudes_remeasure")); @@ -2196,6 +2201,23 @@ void AmplitudeView::init() { SC_D.ui.toolBarSetup->insertWidget(SC_D.ui.actionPickAmplitude, SC_D.labelAmpCombiner = new QLabel("Amp.combiner:")); SC_D.ui.toolBarSetup->insertWidget(SC_D.ui.actionPickAmplitude, SC_D.comboAmpCombiner); + SC_D.audioSonification = new WaveformAudio(this); + connect(SC_D.ui.actionToggleAudioSonification, SIGNAL(triggered(bool)), + this, SLOT(toggleAudioSonification())); + connect(SC_D.ui.actionPlayTraceAudio, SIGNAL(triggered(bool)), + this, SLOT(playCurrentTraceAudio())); + connect(SC_D.ui.actionStopAudioPlayback, SIGNAL(triggered(bool)), + this, SLOT(stopAudioPlayback())); + SC_D.ui.toolBarSonification->addAction(SC_D.ui.actionToggleAudioSonification); + SC_D.ui.toolBarSonification->addAction(SC_D.ui.actionPlayTraceAudio); + SC_D.ui.toolBarSonification->addAction(SC_D.ui.actionStopAudioPlayback); + + SC_D.audioDebounceTimer = new QTimer(this); + SC_D.audioDebounceTimer->setSingleShot(true); + SC_D.audioDebounceTimer->setInterval(300); + connect(SC_D.audioDebounceTimer, &QTimer::timeout, + this, &AmplitudeView::playAudioAtCursor); + // TTT selection SC_D.comboTTT = new QComboBox; SC_D.ui.toolBarTTT->addWidget(SC_D.comboTTT); @@ -4639,6 +4661,10 @@ void AmplitudeView::updateSubCursor(RecordWidget* w, int s) { SC_D.recordView->currentItem()->widget()->blockSignals(true); SC_D.recordView->currentItem()->widget()->setCursorPos(w->cursorPos()); SC_D.recordView->currentItem()->widget()->blockSignals(false); + + if ( SC_D.audioSonification->isEnabled() ) { + SC_D.audioDebounceTimer->start(); + } } // <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<< @@ -5083,12 +5109,14 @@ void AmplitudeView::moveTraces(double offset) { setTimeRange(SC_D.currentRecord->tmin() + offset, SC_D.currentRecord->tmax() + offset); + + if ( SC_D.audioSonification->isEnabled() ) { + SC_D.audioDebounceTimer->start(); + } } // <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<< - - // >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> void AmplitudeView::move(double offset) { if ( fabs(offset) < 0.001 ) return; @@ -5113,6 +5141,10 @@ void AmplitudeView::move(double offset) { SC_D.recordView->move(offset); setTimeRange(tmin, tmax); + + if ( SC_D.audioSonification->isEnabled() ) { + SC_D.audioDebounceTimer->start(); + } } // <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<< @@ -7019,6 +7051,222 @@ void AmplitudeView::announceToScreenReader(const QString &msg) { // <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<< +// >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> +void AmplitudeView::keyPressEvent(QKeyEvent *event) { + if ( event->key() == Qt::Key_Menu || event->key() == Qt::Key_Context1 ) { + RecordViewItem *item = SC_D.recordView->currentItem(); + if ( item && item->widget() ) { + RecordWidget *widget = item->widget(); + + AmplitudeViewMarker *marker = static_cast(widget->currentMarker()); + if ( !marker ) { + marker = static_cast(widget->marker(widget->cursorText())); + } + + if ( marker && (marker->isAmplitude() || marker->isReference()) ) { + SC_D.currentRecord->setCursorText(widget->cursorText()); + SC_D.currentRecord->setCursorPos(widget->cursorPos()); + QPoint centerPos = SC_D.currentRecord->rect().center(); + + if ( SC_D.currentRecord->contextMenuPolicy() == Qt::CustomContextMenu ) + emit SC_D.currentRecord->customContextMenuRequested(centerPos); + + event->accept(); + return; + } + else if ( !widget->cursorText().isEmpty() ) { + SC_D.currentRecord->setCursorText(widget->cursorText()); + SC_D.currentRecord->setCursorPos(widget->cursorPos()); + QPoint centerPos = SC_D.currentRecord->rect().center(); + + if ( SC_D.currentRecord->contextMenuPolicy() == Qt::CustomContextMenu ) + emit SC_D.currentRecord->customContextMenuRequested(centerPos); + + event->accept(); + return; + } + } + + announceToScreenReader(tr("No context menu available for current selection")); + event->accept(); + return; + } + + QMainWindow::keyPressEvent(event); +} +// <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<< + + +// >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> +void AmplitudeView::toggleAudioSonification() { + SC_D.audioSonification->setEnabled(!SC_D.audioSonification->isEnabled()); + + SC_D.ui.actionToggleAudioSonification->setChecked(SC_D.audioSonification->isEnabled()); + + if ( SC_D.audioSonification->isEnabled() ) { + statusBar()->showMessage(tr("Audio sonification enabled - press Ctrl+Space to play"), 5000); + } + else { + statusBar()->showMessage(tr("Audio sonification disabled"), 5000); + } +} +// <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<< + + +// >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> +void AmplitudeView::playCurrentTraceAudio() { + if ( !SC_D.audioSonification->isEnabled() ) { + statusBar()->showMessage(tr("Audio sonification is disabled - enable with Ctrl+Shift+A first"), 5000); + return; + } + + if ( !SC_D.currentRecord ) { + return; + } + + auto seq = SC_D.currentRecord->isFilteringEnabled() + ? SC_D.currentRecord->filteredRecords(SC_D.currentSlot) + : SC_D.currentRecord->records(SC_D.currentSlot); + + if ( !seq ) { + statusBar()->showMessage(tr("No waveform data available for sonification"), 5000); + return; + } + + std::vector waveformData; + double originalSampleRate = 0.0; + + for ( auto it = seq->begin(); it != seq->end(); ++it ) { + auto rec = *it; + if ( !rec || !rec->data() ) continue; + + if ( originalSampleRate == 0.0 ) { + originalSampleRate = rec->samplingFrequency(); + } + + auto dArray = DoubleArray::ConstCast(rec->data()); + if ( dArray ) { + for ( int i = 0; i < dArray->size(); ++i ) { + waveformData.push_back((*dArray)[i]); + } + } + else { + auto fArray = FloatArray::ConstCast(rec->data()); + if ( fArray ) { + for ( int i = 0; i < fArray->size(); ++i ) { + waveformData.push_back(static_cast((*fArray)[i])); + } + } + } + } + + if ( waveformData.empty() ) { + statusBar()->showMessage(tr("No waveform data available for sonification"), 5000); + return; + } + + SC_D.audioSonification->setWaveformData(waveformData, originalSampleRate, 160.0f); + + double dataDuration = SC_D.audioSonification->dataDurationSec(); + int audioDurationMs = SC_D.audioSonification->audioDurationMs(); + + statusBar()->showMessage( + tr("Playing %1s of seismic data at %2 Hz, speeded up 160x (audio: %3 ms)") + .arg(dataDuration, 0, 'f', 1) + .arg(originalSampleRate, 0, 'f', 1) + .arg(audioDurationMs), + 5000 + ); + + SC_D.audioSonification->play(); +} +// <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<< + + +// >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> +void AmplitudeView::stopAudioPlayback() { + SC_D.audioSonification->stop(); + statusBar()->showMessage(tr("Audio playback stopped"), 3000); +} +// <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<< + + +// >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> +void AmplitudeView::playAudioAtCursor() { + if ( !SC_D.audioSonification->isEnabled() ) { + return; + } + + if ( !SC_D.currentRecord ) { + return; + } + + auto seq = SC_D.currentRecord->isFilteringEnabled() + ? SC_D.currentRecord->filteredRecords(SC_D.currentSlot) + : SC_D.currentRecord->records(SC_D.currentSlot); + + if ( !seq ) { + return; + } + + double windowSecs = 15.0; + Core::Time cursorTime = SC_D.currentRecord->cursorPos(); + Core::Time startTime = cursorTime - Core::TimeSpan(windowSecs * 0.2); + Core::Time endTime = cursorTime + Core::TimeSpan(windowSecs * 0.8); + + std::vector waveformData; + double originalSampleRate = 0.0; + + for ( auto it = seq->begin(); it != seq->end(); ++it ) { + auto rec = *it; + if ( !rec || !rec->data() ) continue; + + if ( originalSampleRate == 0.0 ) { + originalSampleRate = rec->samplingFrequency(); + } + + if ( rec->endTime() < startTime || rec->startTime() > endTime ) { + continue; + } + + auto dArray = DoubleArray::ConstCast(rec->data()); + if ( dArray ) { + double fs = rec->samplingFrequency(); + int startIdx = std::max(0, static_cast( + static_cast(startTime - rec->startTime()) * fs)); + int endIdx = std::min(dArray->size(), static_cast( + static_cast(endTime - rec->startTime()) * fs) + 1); + + for ( int i = startIdx; i < endIdx; ++i ) { + waveformData.push_back((*dArray)[i]); + } + } + else { + auto fArray = FloatArray::ConstCast(rec->data()); + if ( fArray ) { + double fs = rec->samplingFrequency(); + int startIdx = std::max(0, static_cast( + static_cast(startTime - rec->startTime()) * fs)); + int endIdx = std::min(fArray->size(), static_cast( + static_cast(endTime - rec->startTime()) * fs) + 1); + + for ( int i = startIdx; i < endIdx; ++i ) { + waveformData.push_back(static_cast((*fArray)[i])); + } + } + } + } + + if ( waveformData.size() < 10 ) { + return; + } + + SC_D.audioSonification->setWaveformData(waveformData, originalSampleRate, 80.0f); + + SC_D.audioSonification->play(); +} +// <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<< + } } diff --git a/libs/seiscomp/gui/datamodel/amplitudeview.h b/libs/seiscomp/gui/datamodel/amplitudeview.h index 4982475fa..fc04fd12d 100644 --- a/libs/seiscomp/gui/datamodel/amplitudeview.h +++ b/libs/seiscomp/gui/datamodel/amplitudeview.h @@ -46,6 +46,7 @@ #include #include #include +#include namespace Seiscomp { @@ -60,6 +61,7 @@ namespace Gui { class TimeScale; class AmplitudeView; +class WaveformAudio; namespace PrivateAmplitudeView { @@ -277,6 +279,11 @@ class SC_GUI_API AmplitudeView : public QMainWindow { void setCurrentStation(const std::string& networkCode, const std::string& stationCode); + void toggleAudioSonification(); + void playCurrentTraceAudio(); + void stopAudioPlayback(); + void playAudioAtCursor(); + signals: void magnitudeCreated(Seiscomp::DataModel::Magnitude*); @@ -399,6 +406,7 @@ class SC_GUI_API AmplitudeView : public QMainWindow { protected: void showEvent(QShowEvent* event); void changeEvent(QEvent *e) override; + void keyPressEvent(QKeyEvent *event) override; RecordLabel* createLabel(RecordViewItem*) const; diff --git a/libs/seiscomp/gui/datamodel/amplitudeview.ui b/libs/seiscomp/gui/datamodel/amplitudeview.ui index bb0b5f756..14cb5ac4b 100644 --- a/libs/seiscomp/gui/datamodel/amplitudeview.ui +++ b/libs/seiscomp/gui/datamodel/amplitudeview.ui @@ -493,8 +493,28 @@ - - + + + + Sonification + + + Qt::Horizontal + + + + 24 + 24 + + + + TopToolBarArea + + + false + + + 0 @@ -582,12 +602,21 @@ - - - - - - + + + &Tools + + + + + + + + + + + + Travel times @@ -1218,15 +1247,54 @@ Space - - - Reset Scale - - - W - - - + + + Reset Scale + + + W + + + + + Toggle audio sonification + + + Toggle audio + + + Enable/disable audio sonification of waveforms (Ctrl+Shift+A) + + + Ctrl+Shift+A + + + + + Play trace audio + + + Play audio + + + Play current trace as audio (Ctrl+Space) + + + Ctrl+Space + + + + + Stop audio playback + + + Stop current audio sonification playback (Ctrl+Shift+Space) + + + Ctrl+Shift+Space + + + Seiscomp::Gui::ZoomRecordFrame diff --git a/libs/seiscomp/gui/datamodel/amplitudeview_p.h b/libs/seiscomp/gui/datamodel/amplitudeview_p.h index 1753412da..16282c61f 100644 --- a/libs/seiscomp/gui/datamodel/amplitudeview_p.h +++ b/libs/seiscomp/gui/datamodel/amplitudeview_p.h @@ -48,6 +48,8 @@ namespace Ui { namespace Seiscomp { namespace Gui { +class WaveformAudio; + class AmplitudeViewPrivate { private: @@ -131,6 +133,9 @@ class AmplitudeViewPrivate { RecordWidgetDecorator *zoomDecorator; RecordWidgetDecorator *generalDecorator; + WaveformAudio *audioSonification{nullptr}; + QTimer *audioDebounceTimer{nullptr}; + AmplitudeView::Config config; ::Ui::AmplitudeView ui; From ee09d61250f953f7740d901130673b05937ae116 Mon Sep 17 00:00:00 2001 From: Donavin97 Date: Sun, 7 Jun 2026 08:20:14 +0000 Subject: [PATCH 16/23] Wire up announceToScreenReader with QAccessibleAnnouncementEvent AmplitudeView (QMainWindow): uses statusBar()->showMessage() for visual feedback and QAccessibleAnnouncementEvent for screen readers. OriginLocatorView, EventEdit, MagnitudeView, CalculateAmplitudes (QWidget): use QAccessibleAnnouncementEvent + SEISCOMP_DEBUG log. All were previously no-op stubs. --- libs/seiscomp/gui/datamodel/amplitudeview.cpp | 6 +++++- libs/seiscomp/gui/datamodel/calculateamplitudes.cpp | 6 +++++- libs/seiscomp/gui/datamodel/eventedit.cpp | 6 +++++- libs/seiscomp/gui/datamodel/magnitudeview.cpp | 6 +++++- libs/seiscomp/gui/datamodel/originlocatorview.cpp | 10 +++++----- 5 files changed, 25 insertions(+), 9 deletions(-) diff --git a/libs/seiscomp/gui/datamodel/amplitudeview.cpp b/libs/seiscomp/gui/datamodel/amplitudeview.cpp index 261294827..2792b9506 100644 --- a/libs/seiscomp/gui/datamodel/amplitudeview.cpp +++ b/libs/seiscomp/gui/datamodel/amplitudeview.cpp @@ -7046,7 +7046,11 @@ bool AmplitudeView::setArrivalState(RecordWidget* w, int arrivalId, bool state) // >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> void AmplitudeView::announceToScreenReader(const QString &msg) { - Q_UNUSED(msg); + statusBar()->showMessage(msg, 5000); +#if QT_VERSION >= QT_VERSION_CHECK(6, 8, 0) + QAccessibleAnnouncementEvent event(this, msg); + QAccessible::updateAccessibility(&event); +#endif } // <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<< diff --git a/libs/seiscomp/gui/datamodel/calculateamplitudes.cpp b/libs/seiscomp/gui/datamodel/calculateamplitudes.cpp index 1768eba58..c4c04423b 100644 --- a/libs/seiscomp/gui/datamodel/calculateamplitudes.cpp +++ b/libs/seiscomp/gui/datamodel/calculateamplitudes.cpp @@ -1172,7 +1172,11 @@ void CalculateAmplitudes::filterView(int startRow, int cnt) { } } void CalculateAmplitudes::announceToScreenReader(const QString &msg) { - Q_UNUSED(msg); + SEISCOMP_DEBUG("Screen reader announcement: %s", msg.toStdString().c_str()); +#if QT_VERSION >= QT_VERSION_CHECK(6, 8, 0) + QAccessibleAnnouncementEvent event(this, msg); + QAccessible::updateAccessibility(&event); +#endif } diff --git a/libs/seiscomp/gui/datamodel/eventedit.cpp b/libs/seiscomp/gui/datamodel/eventedit.cpp index 5ec9b9753..56623858f 100644 --- a/libs/seiscomp/gui/datamodel/eventedit.cpp +++ b/libs/seiscomp/gui/datamodel/eventedit.cpp @@ -4367,7 +4367,11 @@ void EventEdit::evalResultError(const QString &oid, // >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> void EventEdit::announceToScreenReader(const QString &message) { - // TODO: use QAccessibleAnnouncementEvent when available + SEISCOMP_DEBUG("Screen reader announcement: %s", message.toStdString().c_str()); +#if QT_VERSION >= QT_VERSION_CHECK(6, 8, 0) + QAccessibleAnnouncementEvent event(this, message); + QAccessible::updateAccessibility(&event); +#endif } // <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<< diff --git a/libs/seiscomp/gui/datamodel/magnitudeview.cpp b/libs/seiscomp/gui/datamodel/magnitudeview.cpp index bd2d5d8d2..bd8d9a7c7 100644 --- a/libs/seiscomp/gui/datamodel/magnitudeview.cpp +++ b/libs/seiscomp/gui/datamodel/magnitudeview.cpp @@ -4196,7 +4196,11 @@ void MagnitudeView::magnitudeCommentChanged(QString) { // >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> void MagnitudeView::announceToScreenReader(const QString &message) { - // TODO: use QAccessibleAnnouncementEvent when available + SEISCOMP_DEBUG("Screen reader announcement: %s", message.toStdString().c_str()); +#if QT_VERSION >= QT_VERSION_CHECK(6, 8, 0) + QAccessibleAnnouncementEvent event(this, message); + QAccessible::updateAccessibility(&event); +#endif } // <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<< diff --git a/libs/seiscomp/gui/datamodel/originlocatorview.cpp b/libs/seiscomp/gui/datamodel/originlocatorview.cpp index 9c41c8751..adc6933bb 100644 --- a/libs/seiscomp/gui/datamodel/originlocatorview.cpp +++ b/libs/seiscomp/gui/datamodel/originlocatorview.cpp @@ -7022,11 +7022,11 @@ void OriginLocatorView::setScript1(const std::string &script) { // >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> void OriginLocatorView::announceToScreenReader(const QString &message) { - // TODO: Implement screen reader announcement using - // QAccessibleAnnouncementEvent or a status bar when available. - // OriginLocatorView is a QWidget (not QMainWindow), so there is - // no statusBar() available. - Q_UNUSED(message); + SEISCOMP_DEBUG("Screen reader announcement: %s", message.toStdString().c_str()); +#if QT_VERSION >= QT_VERSION_CHECK(6, 8, 0) + QAccessibleAnnouncementEvent event(this, message); + QAccessible::updateAccessibility(&event); +#endif } // <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<< From 1e7ea40fe0480993ec6d8d28583337233efcdde9 Mon Sep 17 00:00:00 2001 From: Donavin97 Date: Sun, 7 Jun 2026 08:24:45 +0000 Subject: [PATCH 17/23] Remove visual statusBar messages from screen reader announcements announceToScreenReader no longer calls statusBar()->showMessage(). Screen reader announcements are silent to sighted users, using only QAccessibleAnnouncementEvent. Direct statusBar calls for audio sonification and error states are preserved as legitimate UI feedback. --- libs/seiscomp/gui/datamodel/amplitudeview.cpp | 2 +- libs/seiscomp/gui/datamodel/pickerview.cpp | 6 +----- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/libs/seiscomp/gui/datamodel/amplitudeview.cpp b/libs/seiscomp/gui/datamodel/amplitudeview.cpp index 2792b9506..6dcd870fe 100644 --- a/libs/seiscomp/gui/datamodel/amplitudeview.cpp +++ b/libs/seiscomp/gui/datamodel/amplitudeview.cpp @@ -7046,7 +7046,7 @@ bool AmplitudeView::setArrivalState(RecordWidget* w, int arrivalId, bool state) // >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> void AmplitudeView::announceToScreenReader(const QString &msg) { - statusBar()->showMessage(msg, 5000); + SEISCOMP_DEBUG("Screen reader announcement: %s", msg.toStdString().c_str()); #if QT_VERSION >= QT_VERSION_CHECK(6, 8, 0) QAccessibleAnnouncementEvent event(this, msg); QAccessible::updateAccessibility(&event); diff --git a/libs/seiscomp/gui/datamodel/pickerview.cpp b/libs/seiscomp/gui/datamodel/pickerview.cpp index d21578131..d63ded748 100644 --- a/libs/seiscomp/gui/datamodel/pickerview.cpp +++ b/libs/seiscomp/gui/datamodel/pickerview.cpp @@ -9728,12 +9728,8 @@ void PickerView::gotoPreviousMarker() { // >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> void PickerView::announceToScreenReader(const QString &message) { // Announce message to screen readers using Qt accessibility APIs - // This provides feedback for Orca and other screen readers + // No visual status bar output — keeps the UI clean for sighted users - // Method 1: Status bar message (for debugging and visual feedback) - statusBar()->showMessage(message, 5000); - - // Always log for debugging SEISCOMP_DEBUG("Screen reader announcement: %s", message.toStdString().c_str()); // Check accessibility status From b0c73baa4ded919fdcfaefdaeb23de84da9f84d2 Mon Sep 17 00:00:00 2001 From: Donavin97 Date: Sun, 7 Jun 2026 08:33:55 +0000 Subject: [PATCH 18/23] Remove duplicate statusBar message from trace selection announcement --- libs/seiscomp/gui/datamodel/pickerview.cpp | 1 - 1 file changed, 1 deletion(-) diff --git a/libs/seiscomp/gui/datamodel/pickerview.cpp b/libs/seiscomp/gui/datamodel/pickerview.cpp index d63ded748..752c6f66f 100644 --- a/libs/seiscomp/gui/datamodel/pickerview.cpp +++ b/libs/seiscomp/gui/datamodel/pickerview.cpp @@ -3022,7 +3022,6 @@ void PickerView::init() { info += tr(", azimuth %1°").arg(current->value(ITEM_AZIMUTH_INDEX), 0, 'f', 1); } - statusBar()->showMessage(info, 3000); announceToScreenReader(info); } }); From 1dc924b3d658ff2ca11d79dac737c1d7b350c1f3 Mon Sep 17 00:00:00 2001 From: Donavin97 Date: Sun, 7 Jun 2026 08:53:32 +0000 Subject: [PATCH 19/23] Add screen reader announcements to dialogs Commit dialog: announces on open and accept with evaluation status, event type, and phase count. SelectStation: filter results count, station selection in table. PickerSettings: slider release values (pre/post offset, amplitude offsets), settings saved confirmation. OriginDialog: origin coordinates on accept. --- libs/seiscomp/gui/datamodel/origindialog.cpp | 22 ++++++++++++ .../gui/datamodel/originlocatorview.cpp | 19 +++++++++++ .../seiscomp/gui/datamodel/pickersettings.cpp | 34 +++++++++++++++++++ libs/seiscomp/gui/datamodel/selectstation.cpp | 26 ++++++++++++++ 4 files changed, 101 insertions(+) diff --git a/libs/seiscomp/gui/datamodel/origindialog.cpp b/libs/seiscomp/gui/datamodel/origindialog.cpp index d36dafc52..d6b6c4ef3 100644 --- a/libs/seiscomp/gui/datamodel/origindialog.cpp +++ b/libs/seiscomp/gui/datamodel/origindialog.cpp @@ -22,6 +22,10 @@ #include #include #include +#include +#if QT_VERSION >= QT_VERSION_CHECK(6, 8, 0) +#include +#endif #include @@ -29,6 +33,17 @@ namespace Seiscomp { namespace Gui { +namespace { + +void announceToScreenReader(QWidget *widget, const QString &message) { +#if QT_VERSION >= QT_VERSION_CHECK(6, 8, 0) + QAccessibleAnnouncementEvent event(widget, message); + QAccessible::updateAccessibility(&event); +#endif +} + +} + double OriginDialog::_defaultDepth = 10; @@ -291,6 +306,13 @@ void OriginDialog::init(double lon, double lat, double dep) { setAdvanced(false); setPhaseCount(10); setMagValue(5.0); + + connect(this, &QDialog::finished, this, [this](int result) { + if ( result == QDialog::Accepted ) { + announceToScreenReader(this, tr("Origin set: lat %1, lon %2, depth %3") + .arg(latitude()).arg(longitude()).arg(depth())); + } + }); } diff --git a/libs/seiscomp/gui/datamodel/originlocatorview.cpp b/libs/seiscomp/gui/datamodel/originlocatorview.cpp index adc6933bb..1f2872ce8 100644 --- a/libs/seiscomp/gui/datamodel/originlocatorview.cpp +++ b/libs/seiscomp/gui/datamodel/originlocatorview.cpp @@ -7818,6 +7818,16 @@ void OriginLocatorView::commitWithOptions(const void *data_ptr) { OriginCommitOptions dlg; tmp = *options_ptr; dlg.setOptions(tmp, SC_D.baseEvent.get(), SC_D.localOrigin); + + { + QString evalStatus = tmp.originStatus && *tmp.originStatus + ? QString::fromStdString((*tmp.originStatus)->toString()) : QString("unset"); + QString evType = tmp.eventType + ? QString::fromStdString(tmp.eventType->toString()) : QString("unset"); + announceToScreenReader(tr("Committing origin with evaluation status %1, event type %2, %3 phases") + .arg(evalStatus).arg(evType).arg(SC_D.currentOrigin->arrivalCount())); + } + if ( dlg.exec() != QDialog::Accepted ) { return; } @@ -7836,6 +7846,15 @@ void OriginLocatorView::commitWithOptions(const void *data_ptr) { } dialogConfirmed = true; + + { + QString evalStatus = tmp.originStatus && *tmp.originStatus + ? QString::fromStdString((*tmp.originStatus)->toString()) : QString("unset"); + QString evType = tmp.eventType + ? QString::fromStdString(tmp.eventType->toString()) : QString("unset"); + announceToScreenReader(tr("Committing origin with evaluation status %1, event type %2, %3 phases") + .arg(evalStatus).arg(evType).arg(SC_D.currentOrigin->arrivalCount())); + } } const CommitOptions &options = *options_ptr; diff --git a/libs/seiscomp/gui/datamodel/pickersettings.cpp b/libs/seiscomp/gui/datamodel/pickersettings.cpp index d164352c2..54f6c3078 100644 --- a/libs/seiscomp/gui/datamodel/pickersettings.cpp +++ b/libs/seiscomp/gui/datamodel/pickersettings.cpp @@ -21,6 +21,10 @@ #include #include #include +#include +#if QT_VERSION >= QT_VERSION_CHECK(6, 8, 0) +#include +#endif namespace Seiscomp { @@ -138,6 +142,13 @@ class FilterModel : public QAbstractListModel { FilterList &_data; }; +void announceToScreenReader(QWidget *widget, const QString &message) { +#if QT_VERSION >= QT_VERSION_CHECK(6, 8, 0) + QAccessibleAnnouncementEvent event(widget, message); + QAccessible::updateAccessibility(&event); +#endif +} + } @@ -247,6 +258,29 @@ PickerSettings::PickerSettings(const OriginLocatorView::Config &c1, connect(_ui.saveButton, SIGNAL(clicked()), this, SLOT(save())); + connect(this, &QDialog::finished, this, [this](int result) { + if ( result == QDialog::Accepted ) { + announceToScreenReader(this, tr("Picker settings saved")); + } + }); + + connect(_ui.slPreOffset, &QSlider::sliderReleased, this, [this]() { + announceToScreenReader(this, tr("Pre-offset set to %1 minutes") + .arg(_ui.slPreOffset->value())); + }); + connect(_ui.slPostOffset, &QSlider::sliderReleased, this, [this]() { + announceToScreenReader(this, tr("Post-offset set to %1 minutes") + .arg(_ui.slPostOffset->value())); + }); + connect(_ui.slAmplitudePreOffset, &QSlider::sliderReleased, this, [this]() { + announceToScreenReader(this, tr("Amplitude pre-offset set to %1 minutes") + .arg(_ui.slAmplitudePreOffset->value())); + }); + connect(_ui.slAmplitudePostOffset, &QSlider::sliderReleased, this, [this]() { + announceToScreenReader(this, tr("Amplitude post-offset set to %1 minutes") + .arg(_ui.slAmplitudePostOffset->value())); + }); + _ui.spinPVel->setValue(_locatorConfig.reductionVelocityP); _ui.cbMaplines->setChecked(_locatorConfig.drawMapLines); _ui.cbPlotGridlines->setChecked(_locatorConfig.drawGridLines); diff --git a/libs/seiscomp/gui/datamodel/selectstation.cpp b/libs/seiscomp/gui/datamodel/selectstation.cpp index c7480b48f..157292ff1 100644 --- a/libs/seiscomp/gui/datamodel/selectstation.cpp +++ b/libs/seiscomp/gui/datamodel/selectstation.cpp @@ -33,6 +33,10 @@ #include #include #include +#include +#if QT_VERSION >= QT_VERSION_CHECK(6, 8, 0) +#include +#endif namespace Seiscomp { @@ -40,6 +44,15 @@ namespace Gui { namespace { +void announceToScreenReader(QWidget *widget, const QString &message) { +#if QT_VERSION >= QT_VERSION_CHECK(6, 8, 0) + QAccessibleAnnouncementEvent event(widget, message); + QAccessible::updateAccessibility(&event); +#endif +} + + + QString parse(QString s) { QString r; @@ -469,6 +482,16 @@ void SelectStation::init(Core::Time time, bool ignoreDisabledStations, connect(_ui.cbExcludeSensorUnit, SIGNAL(toggled(bool)), this, SLOT(listMatchingStations())); + connect(_ui.table->selectionModel(), &QItemSelectionModel::selectionChanged, + this, [this](const QItemSelection &selected, const QItemSelection &) { + if ( selected.isEmpty() ) return; + QModelIndex idx = selected.indexes().first(); + if ( idx.column() != 0 ) { + idx = idx.sibling(idx.row(), 0); + } + announceToScreenReader(this, tr("Selected station %1").arg(idx.data().toString())); + }); + listMatchingStations(); } // <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<< @@ -519,6 +542,9 @@ void SelectStation::listMatchingStations() { model->includeSensorUnit(!_ui.cbExcludeSensorUnit->isChecked()); model->setFilterWildcard(_ui.lineEditNSLC->text().trimmed()); + + announceToScreenReader(this, tr("Showing %1 stations matching filter") + .arg(_ui.table->model()->rowCount())); } // <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<< From 9abfb32e42bbd04559d8238e8231e97efb4d2ad2 Mon Sep 17 00:00:00 2001 From: Donavin97 Date: Sun, 7 Jun 2026 09:17:53 +0000 Subject: [PATCH 20/23] Make commit button dropdown keyboard-accessible - Set QToolButton::MenuButtonPopup mode on btnCommit so the dropdown arrow is focusable and accessible via keyboard - Add Ctrl+Shift+Return shortcut to open commit options dialog directly - Update tooltip to inform users about both access methods --- libs/seiscomp/gui/datamodel/originlocatorview.cpp | 3 +++ 1 file changed, 3 insertions(+) diff --git a/libs/seiscomp/gui/datamodel/originlocatorview.cpp b/libs/seiscomp/gui/datamodel/originlocatorview.cpp index 1f2872ce8..43c92b993 100644 --- a/libs/seiscomp/gui/datamodel/originlocatorview.cpp +++ b/libs/seiscomp/gui/datamodel/originlocatorview.cpp @@ -3115,8 +3115,11 @@ void OriginLocatorView::init() { SC_D.commitMenu = new QMenu(this); SC_D.actionCommitOptions = SC_D.commitMenu->addAction("With additional options..."); + SC_D.actionCommitOptions->setShortcut(QKeySequence("Ctrl+Shift+Return")); SC_D.ui.btnCommit->setMenu(SC_D.commitMenu); + SC_D.ui.btnCommit->setPopupMode(QToolButton::MenuButtonPopup); + SC_D.ui.btnCommit->setToolTip(tr("Commit origin (press and hold for menu, or use Ctrl+Shift+Enter for options dialog)")); SC_D.ui.editFixedDepth->setValidator(new QDoubleValidator(0, 1000.0, 3, SC_D.ui.editFixedDepth)); SC_D.ui.editDistanceCutOff->setValidator(new QDoubleValidator(0, 25000.0, 3, SC_D.ui.editFixedDepth)); From a19286d2ddb0516e4df85f27402ad3587ba591df Mon Sep 17 00:00:00 2001 From: Donavin97 Date: Sun, 7 Jun 2026 09:27:48 +0000 Subject: [PATCH 21/23] Replace direct commit with menu-driven commit on keyboard - Set InstantPopup mode on commit button: Space/Enter opens the menu instead of committing directly, preventing accidental commits - Menu now has 'Commit' (Ctrl+Return) for direct commit and 'With additional options...' (Ctrl+Shift+Return) for the dialog - Both accessible via keyboard without needing mouse --- libs/seiscomp/gui/datamodel/originlocatorview.cpp | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/libs/seiscomp/gui/datamodel/originlocatorview.cpp b/libs/seiscomp/gui/datamodel/originlocatorview.cpp index 43c92b993..df22a73ac 100644 --- a/libs/seiscomp/gui/datamodel/originlocatorview.cpp +++ b/libs/seiscomp/gui/datamodel/originlocatorview.cpp @@ -3114,12 +3114,15 @@ void OriginLocatorView::init() { SC_D.ui.btnCustom1->setVisible(false); SC_D.commitMenu = new QMenu(this); + SC_D.commitMenu->addAction(tr("Commit"))->setShortcut(QKeySequence("Ctrl+Return")); + connect(SC_D.commitMenu->actions().first(), SIGNAL(triggered()), this, SLOT(commit())); + SC_D.commitMenu->addSeparator(); SC_D.actionCommitOptions = SC_D.commitMenu->addAction("With additional options..."); SC_D.actionCommitOptions->setShortcut(QKeySequence("Ctrl+Shift+Return")); SC_D.ui.btnCommit->setMenu(SC_D.commitMenu); - SC_D.ui.btnCommit->setPopupMode(QToolButton::MenuButtonPopup); - SC_D.ui.btnCommit->setToolTip(tr("Commit origin (press and hold for menu, or use Ctrl+Shift+Enter for options dialog)")); + SC_D.ui.btnCommit->setPopupMode(QToolButton::InstantPopup); + SC_D.ui.btnCommit->setToolTip(tr("Commit origin with options (Ctrl+Shift+Enter)")); SC_D.ui.editFixedDepth->setValidator(new QDoubleValidator(0, 1000.0, 3, SC_D.ui.editFixedDepth)); SC_D.ui.editDistanceCutOff->setValidator(new QDoubleValidator(0, 25000.0, 3, SC_D.ui.editFixedDepth)); From 3c5e90544d962f18a8429e3cd362266c04c5074a Mon Sep 17 00:00:00 2001 From: Donavin97 Date: Sun, 7 Jun 2026 09:38:54 +0000 Subject: [PATCH 22/23] Space/Enter commits directly, Ctrl+Return opens commit dialog - Space/Enter on commit button commits immediately (original behavior) - Ctrl+Return opens 'With additional options...' commit dialog - MenuButtonPopup mode gives dropdown arrow for mouse users too --- libs/seiscomp/gui/datamodel/originlocatorview.cpp | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/libs/seiscomp/gui/datamodel/originlocatorview.cpp b/libs/seiscomp/gui/datamodel/originlocatorview.cpp index df22a73ac..fb5805ed2 100644 --- a/libs/seiscomp/gui/datamodel/originlocatorview.cpp +++ b/libs/seiscomp/gui/datamodel/originlocatorview.cpp @@ -3114,15 +3114,14 @@ void OriginLocatorView::init() { SC_D.ui.btnCustom1->setVisible(false); SC_D.commitMenu = new QMenu(this); - SC_D.commitMenu->addAction(tr("Commit"))->setShortcut(QKeySequence("Ctrl+Return")); + SC_D.commitMenu->addAction(tr("Commit"))->setShortcut(QKeySequence("Return")); connect(SC_D.commitMenu->actions().first(), SIGNAL(triggered()), this, SLOT(commit())); SC_D.commitMenu->addSeparator(); SC_D.actionCommitOptions = SC_D.commitMenu->addAction("With additional options..."); - SC_D.actionCommitOptions->setShortcut(QKeySequence("Ctrl+Shift+Return")); - + SC_D.actionCommitOptions->setShortcut(QKeySequence("Ctrl+Return")); SC_D.ui.btnCommit->setMenu(SC_D.commitMenu); - SC_D.ui.btnCommit->setPopupMode(QToolButton::InstantPopup); - SC_D.ui.btnCommit->setToolTip(tr("Commit origin with options (Ctrl+Shift+Enter)")); + SC_D.ui.btnCommit->setPopupMode(QToolButton::MenuButtonPopup); + SC_D.ui.btnCommit->setToolTip(tr("Commit (Enter). Ctrl+Enter for options dialog")); SC_D.ui.editFixedDepth->setValidator(new QDoubleValidator(0, 1000.0, 3, SC_D.ui.editFixedDepth)); SC_D.ui.editDistanceCutOff->setValidator(new QDoubleValidator(0, 25000.0, 3, SC_D.ui.editFixedDepth)); From bce4f99aac6c154e5aee2a92fe9955a7f8c7159d Mon Sep 17 00:00:00 2001 From: Donavin97 Date: Sun, 7 Jun 2026 09:42:42 +0000 Subject: [PATCH 23/23] Add Ctrl+Enter to popup commit menu, Ctrl+Shift+Enter for options dialog - Enter/Space: commit directly - Ctrl+Enter: show the commit popup menu - Ctrl+Shift+Enter: open 'With additional options...' dialog directly --- libs/seiscomp/gui/datamodel/originlocatorview.cpp | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/libs/seiscomp/gui/datamodel/originlocatorview.cpp b/libs/seiscomp/gui/datamodel/originlocatorview.cpp index fb5805ed2..d8737e07f 100644 --- a/libs/seiscomp/gui/datamodel/originlocatorview.cpp +++ b/libs/seiscomp/gui/datamodel/originlocatorview.cpp @@ -3114,14 +3114,20 @@ void OriginLocatorView::init() { SC_D.ui.btnCustom1->setVisible(false); SC_D.commitMenu = new QMenu(this); - SC_D.commitMenu->addAction(tr("Commit"))->setShortcut(QKeySequence("Return")); - connect(SC_D.commitMenu->actions().first(), SIGNAL(triggered()), this, SLOT(commit())); + QAction *commitAction = SC_D.commitMenu->addAction(tr("Commit")); + commitAction->setShortcut(QKeySequence("Return")); + connect(commitAction, SIGNAL(triggered()), this, SLOT(commit())); SC_D.commitMenu->addSeparator(); SC_D.actionCommitOptions = SC_D.commitMenu->addAction("With additional options..."); - SC_D.actionCommitOptions->setShortcut(QKeySequence("Ctrl+Return")); + SC_D.actionCommitOptions->setShortcut(QKeySequence("Ctrl+Shift+Return")); SC_D.ui.btnCommit->setMenu(SC_D.commitMenu); SC_D.ui.btnCommit->setPopupMode(QToolButton::MenuButtonPopup); - SC_D.ui.btnCommit->setToolTip(tr("Commit (Enter). Ctrl+Enter for options dialog")); + SC_D.ui.btnCommit->setToolTip(tr("Commit (Enter). Ctrl+Enter for menu, Ctrl+Shift+Enter for options dialog")); + + QAction *showCommitMenu = new QAction(this); + showCommitMenu->setShortcut(QKeySequence("Ctrl+Return")); + connect(showCommitMenu, SIGNAL(triggered()), SC_D.ui.btnCommit, SLOT(showMenu())); + addAction(showCommitMenu); SC_D.ui.editFixedDepth->setValidator(new QDoubleValidator(0, 1000.0, 3, SC_D.ui.editFixedDepth)); SC_D.ui.editDistanceCutOff->setValidator(new QDoubleValidator(0, 25000.0, 3, SC_D.ui.editFixedDepth));