Fix panning sounds after scrolling.

This commit is contained in:
Storm Dragon committed 2026-10-14 12:50:44 -04:00
1 parent b2f558c26c
commit 8e76d2341c
14 files changed
+338 -17

No files matched your search

@@ -4,7 +4,7 @@
pkgbase=cthulhu-git pkgbase=cthulhu-git
pkgname=cthulhu-git pkgname=cthulhu-git
_pkgname=cthulhu _pkgname=cthulhu
pkgver=2026.10.14.r5.g6613237 pkgver=2026.10.14.r6.gb2f558c
pkgrel=1 pkgrel=1
pkgdesc="Desktop-agnostic screen reader with speech, Braille, and Tentacle support" pkgdesc="Desktop-agnostic screen reader with speech, Braille, and Tentacle support"
url="https://git.stormux.org/storm/cthulhu" url="https://git.stormux.org/storm/cthulhu"
+20
View File
@@ -320,6 +320,7 @@ func presentDocumentResult(logger *slog.Logger, speech speechEnqueuer, result re
"source_path", result.DocumentPosition.Object.ID().Path, "source_path", result.DocumentPosition.Object.ID().Path,
"virtual_caret_offset", result.DocumentPosition.Offset, "virtual_caret_offset", result.DocumentPosition.Offset,
"caret_sync_error", result.CaretErr, "caret_sync_error", result.CaretErr,
"sound_geometry_error", result.DocumentSoundErr,
"braille_line_available", result.DocumentBrailleLine != nil, "braille_line_available", result.DocumentBrailleLine != nil,
"braille_read_error", result.DocumentBrailleErr) "braille_read_error", result.DocumentBrailleErr)
} }
@@ -406,6 +407,25 @@ func presentDocumentResult(logger *slog.Logger, speech speechEnqueuer, result re
fallbackText = fallbackSpeech.Text fallbackText = fallbackSpeech.Text
} }
} }
if len(cues) > 0 && logger.Enabled(context.Background(), slog.LevelDebug) {
// Record the captured geometry actually used for cue generation.
// Linked headings use the link's geometry independently of the heading.
attributes := []any{
"bus", result.DocumentPosition.Snapshot.BusName,
"source_path", result.DocumentPosition.Snapshot.Path,
"role", result.DocumentPosition.Snapshot.Role,
"spatialize_object_sounds", options.SpatializeObjectSounds,
"sound_geometry", result.DocumentPosition.Snapshot.SoundGeometry,
"cues", cues,
}
if link := result.DocumentPosition.LinkedHeading; link != nil {
attributes = append(attributes,
"linked_heading_bus", link.BusName,
"linked_heading_path", link.Path,
"linked_heading_sound_geometry", link.SoundGeometry)
}
logger.Debug("document navigation sound positioning", attributes...)
}
role = result.DocumentPosition.Snapshot.Role role = result.DocumentPosition.Snapshot.Role
if result.DocumentWrapped { if result.DocumentWrapped {
if result.DocumentBackwards { if result.DocumentBackwards {
@@ -11,6 +11,7 @@ import (
"time" "time"
"git.stormux.org/storm/cthulhu/internal/atspi" "git.stormux.org/storm/cthulhu/internal/atspi"
"git.stormux.org/storm/cthulhu/internal/presentation"
"git.stormux.org/storm/cthulhu/internal/review" "git.stormux.org/storm/cthulhu/internal/review"
) )
@@ -73,6 +74,9 @@ func TestNativeLinkedHeadings(t *testing.T) {
t.Fatal(ctx.Err()) t.Fatal(ctx.Err())
} }
} }
t.Run("sound geometry after scrolling", func(t *testing.T) {
testLinkedHeadingSoundGeometry(t, ctx, adapter, focus)
})
for _, tc := range []struct { for _, tc := range []struct {
level int level int
name, title string name, title string
@@ -142,6 +146,72 @@ func TestNativeLinkedHeadings(t *testing.T) {
} }
} }
func testLinkedHeadingSoundGeometry(t *testing.T, ctx context.Context, adapter *atspi.Adapter, focus atspi.ObjectID) {
t.Helper()
tree, err := adapter.CaptureDocument(ctx, focus)
if err != nil {
t.Fatal(err)
}
cursor, err := review.NewDocumentCursor(tree)
if err != nil {
t.Fatal(err)
}
if _, err := cursor.SyncFocus(ctx, focus); err != nil {
t.Fatal(err)
}
cold, err := cursor.Move(ctx, review.DocumentHeading, 6, false)
if err != nil || !cold.Moved || cold.Position.LinkedHeading == nil {
t.Fatalf("cold offscreen heading = %+v, %v", cold, err)
}
if cold.Position.LinkedHeading.SoundGeometry.ObjectKnown {
t.Fatal("fixture heading must start offscreen without captured sound geometry")
}
worker, err := review.NewWorker(adapter, 2*time.Second)
if err != nil {
t.Fatal(err)
}
worker.SyncFocus(focus)
if err := worker.Start(ctx); err != nil {
t.Fatal(err)
}
defer worker.Stop(context.Background())
for _, step := range []struct {
backwards bool
name string
}{
{false, "Offscreen first topic"},
{false, "Offscreen second topic"},
{true, "Offscreen first topic"},
} {
if err := worker.MoveDocument(review.DocumentHeading, 6, step.backwards); err != nil {
t.Fatal(err)
}
select {
case result := <-worker.Results():
if result.Err != nil || result.CaretErr != nil || result.DocumentSoundErr != nil ||
!result.Moved || result.DocumentPosition.Snapshot.Name != step.name {
t.Fatalf("scrolled heading result = %+v", result)
}
link := result.DocumentPosition.LinkedHeading
if link == nil || !link.SoundGeometry.ObjectKnown || !link.SoundGeometry.ReferenceKnown {
t.Fatalf("scrolled link lacks fresh geometry: %+v", link)
}
if !result.DocumentPosition.Snapshot.SoundGeometry.ObjectKnown {
t.Fatal("scrolled heading lacks fresh geometry")
}
cues := presentation.SoundCues(*link, presentation.Options{
EnableSound: true, PlaySoundForRole: true, SpatializeObjectSounds: true,
})
if len(cues) != 1 || cues[0].Pan >= -0.3 {
t.Fatalf("left-column link lost leftward sound: %+v", cues)
}
t.Logf("%s after scroll: bounds=%+v pan=%g", step.name, link.SoundGeometry.Object, cues[0].Pan)
case <-ctx.Done():
t.Fatal(ctx.Err())
}
}
}
func linkedHeadingFixtureFocus(tree *atspi.AccessibleTree) atspi.ObjectID { func linkedHeadingFixtureFocus(tree *atspi.AccessibleTree) atspi.ObjectID {
root, err := tree.Root() root, err := tree.Root()
if err != nil { if err != nil {
+12 -10
View File
@@ -170,19 +170,21 @@ type nativeDocumentWindow struct {
} }
type treeCaptureResult struct { type treeCaptureResult struct {
item TransferItem soundGeometry [2]SoundGeometry
actions ActionSet item TransferItem
point PointReview actions ActionSet
ocr OCRGeometry point PointReview
attributes CharacterAttributes ocr OCRGeometry
content DocumentContent attributes CharacterAttributes
tree *AccessibleTree content DocumentContent
owner DocumentContext tree *AccessibleTree
caret DocumentCaretText owner DocumentContext
err error caret DocumentCaretText
err error
} }
type treeCaptureRequest struct { type treeCaptureRequest struct {
soundObjects [2]ObjectReference
transferItem bool transferItem bool
listActions bool listActions bool
actionTarget *ActionTarget actionTarget *ActionTarget
+1 -1
View File
@@ -125,7 +125,7 @@ static gboolean cthulhu_capture_sound_rect(AtspiAccessible *source, gint *x, gin
return TRUE; return TRUE;
} }
static void cthulhu_capture_sound_geometry(AtspiAccessible *source, cthulhu_snapshot *snapshot) { void cthulhu_capture_sound_geometry(AtspiAccessible *source, cthulhu_snapshot *snapshot) {
snapshot->objectGeometryKnown = cthulhu_capture_sound_rect( snapshot->objectGeometryKnown = cthulhu_capture_sound_rect(
source, &snapshot->objectX, &snapshot->objectY, source, &snapshot->objectX, &snapshot->objectY,
&snapshot->objectWidth, &snapshot->objectHeight); &snapshot->objectWidth, &snapshot->objectHeight);
+51
View File
@@ -83,6 +83,57 @@ func (adapter *Adapter) SetDocumentPosition(ctx context.Context, position Object
return err return err
} }
// CaptureDocumentSoundGeometry refreshes only the presented object and its
// optional linked heading target, after navigation has scrolled them into view.
// It never changes the cached document semantics or retains new native handles.
func (adapter *Adapter) CaptureDocumentSoundGeometry(ctx context.Context, object, link ObjectReference) ([2]SoundGeometry, error) {
if !object.Valid() {
return [2]SoundGeometry{}, ErrStaleObject
}
if !adapter.captureSound.Load() {
return [2]SoundGeometry{}, nil
}
result, err := adapter.documentOperation(ctx, treeCaptureRequest{soundObjects: [2]ObjectReference{object, link}})
return result.soundGeometry, err
}
func (adapter *Adapter) captureNativeDocumentSoundGeometry(ctx context.Context, objects [2]ObjectReference) ([2]SoundGeometry, error) {
var geometry [2]SoundGeometry
if !adapter.captureSound.Load() {
return geometry, nil
}
adapter.treeMutex.Lock()
tree := adapter.documentTree
adapter.treeMutex.Unlock()
if tree == nil {
return geometry, ErrStaleObject
}
for index, object := range objects {
if !object.Valid() {
continue
}
if err := ctx.Err(); err != nil {
return [2]SoundGeometry{}, err
}
if _, err := tree.Resolve(object); err != nil {
return [2]SoundGeometry{}, err
}
source := adapter.nativeDocument[object.ID()]
if source == nil {
return [2]SoundGeometry{}, ErrStaleObject
}
// SHOWING can still reflect the pre-scroll viewport in libatspi's cache.
C.atspi_accessible_clear_cache_single(source)
var captured C.cthulhu_snapshot
C.cthulhu_capture_sound_geometry(source, &captured)
geometry[index] = soundGeometryFromNative(captured)
}
if err := ctx.Err(); err != nil {
return [2]SoundGeometry{}, err
}
return geometry, nil
}
// SetDocumentCaretPosition also places the browser caret at the virtual browse // SetDocumentCaretPosition also places the browser caret at the virtual browse
// offset, so native Tab traversal starts from the announced location. // offset, so native Tab traversal starts from the announced location.
func (adapter *Adapter) SetDocumentCaretPosition(ctx context.Context, position ObjectReference, focus ObjectID, offset int) error { func (adapter *Adapter) SetDocumentCaretPosition(ctx context.Context, position ObjectReference, focus ObjectID, offset int) error {
+1
View File
@@ -88,6 +88,7 @@ cthulhu_snapshot cthulhu_capture_snapshot(AtspiAccessible *source, gboolean capt
gboolean captureDocumentMetadata, gboolean captureDocumentMetadata,
gboolean captureSoundGeometry, gboolean captureSoundGeometry,
gboolean captureApplicationName); gboolean captureApplicationName);
void cthulhu_capture_sound_geometry(AtspiAccessible *source, cthulhu_snapshot *snapshot);
gchar *cthulhu_collect_notification_text(AtspiAccessible *source); gchar *cthulhu_collect_notification_text(AtspiAccessible *source);
gchar *cthulhu_collect_simple_prompt_text(AtspiAccessible *source); gchar *cthulhu_collect_simple_prompt_text(AtspiAccessible *source);
gchar *cthulhu_radio_group_name(AtspiAccessible *source); gchar *cthulhu_radio_group_name(AtspiAccessible *source);
+9 -5
View File
@@ -158,11 +158,7 @@ func snapshotFromNativeWithOptions(source *C.AtspiAccessible, text, caretCharact
snapshot.MinimumIncrement = float64(captured.minimumIncrement) snapshot.MinimumIncrement = float64(captured.minimumIncrement)
snapshot.ValueText = sanitizeSnapshotField(C.GoString(captured.valueText)) snapshot.ValueText = sanitizeSnapshotField(C.GoString(captured.valueText))
snapshot.TextBeforeCaret = sanitizeTextContext(C.GoString(captured.textBeforeCaret)) snapshot.TextBeforeCaret = sanitizeTextContext(C.GoString(captured.textBeforeCaret))
snapshot.SoundGeometry = SoundGeometry{ snapshot.SoundGeometry = soundGeometryFromNative(captured)
Object: Rectangle{X: int32(captured.objectX), Y: int32(captured.objectY), Width: int32(captured.objectWidth), Height: int32(captured.objectHeight)},
Reference: Rectangle{X: int32(captured.referenceX), Y: int32(captured.referenceY), Width: int32(captured.referenceWidth), Height: int32(captured.referenceHeight)},
ObjectKnown: captured.objectGeometryKnown != 0, ReferenceKnown: captured.referenceGeometryKnown != 0,
}
C.g_free(C.gpointer(captured.applicationName)) C.g_free(C.gpointer(captured.applicationName))
C.g_free(C.gpointer(captured.name)) C.g_free(C.gpointer(captured.name))
C.g_free(C.gpointer(captured.role)) C.g_free(C.gpointer(captured.role))
@@ -177,6 +173,14 @@ func snapshotFromNativeWithOptions(source *C.AtspiAccessible, text, caretCharact
return snapshot return snapshot
} }
func soundGeometryFromNative(captured C.cthulhu_snapshot) SoundGeometry {
return SoundGeometry{
Object: Rectangle{X: int32(captured.objectX), Y: int32(captured.objectY), Width: int32(captured.objectWidth), Height: int32(captured.objectHeight)},
Reference: Rectangle{X: int32(captured.referenceX), Y: int32(captured.referenceY), Width: int32(captured.referenceWidth), Height: int32(captured.referenceHeight)},
ObjectKnown: captured.objectGeometryKnown != 0, ReferenceKnown: captured.referenceGeometryKnown != 0,
}
}
func nativeObjectID(source *C.AtspiAccessible) ObjectID { func nativeObjectID(source *C.AtspiAccessible) ObjectID {
return NewObjectID( return NewObjectID(
C.GoString(C.cthulhu_accessible_bus_name(source)), C.GoString(C.cthulhu_accessible_bus_name(source)),
+3
View File
@@ -376,6 +376,9 @@ func cthulhuAtspiTreeCaptureCallback(userData unsafe.Pointer) {
} else if request.activate { } else if request.activate {
err := adapter.activateNativeDocumentObject(request.ctx, request.position, request.reviewFocus) err := adapter.activateNativeDocumentObject(request.ctx, request.position, request.reviewFocus)
request.result <- treeCaptureResult{err: err} request.result <- treeCaptureResult{err: err}
} else if request.soundObjects[0].Valid() {
geometry, err := adapter.captureNativeDocumentSoundGeometry(request.ctx, request.soundObjects)
request.result <- treeCaptureResult{soundGeometry: geometry, err: err}
} else if request.position.Valid() { } else if request.position.Valid() {
err := adapter.setNativeDocumentPosition(request.ctx, request.position, request.reviewFocus, request.positionOffset, request.usePositionOffset) err := adapter.setNativeDocumentPosition(request.ctx, request.position, request.reviewFocus, request.positionOffset, request.usePositionOffset)
request.result <- treeCaptureResult{err: err} request.result <- treeCaptureResult{err: err}
+22
View File
@@ -99,6 +99,28 @@ func TestCaptureDesktopRejectsUnavailableAdapterAndCanceledContext(t *testing.T)
} }
} }
func TestDocumentSoundGeometryHonorsCaptureGateAndCancellation(t *testing.T) {
adapter := New()
tree := testTree(t)
object, _ := tree.Reference(testID("/first"))
adapter.SetCaptureSoundGeometry(false)
geometry, err := adapter.CaptureDocumentSoundGeometry(context.Background(), object, ObjectReference{})
if err != nil || geometry != [2]SoundGeometry{} {
t.Fatalf("disabled sound geometry accessed the stopped adapter: %+v, %v", geometry, err)
}
adapter.SetCaptureSoundGeometry(true)
canceled, cancel := context.WithCancel(context.Background())
cancel()
if _, err := adapter.CaptureDocumentSoundGeometry(canceled, object, ObjectReference{}); !errors.Is(err, context.Canceled) {
t.Fatalf("canceled geometry capture error = %v", err)
}
adapter.documentTree = tree
tree.Invalidate(object.ID())
if _, err := adapter.captureNativeDocumentSoundGeometry(context.Background(), [2]ObjectReference{object}); !errors.Is(err, ErrStaleObject) {
t.Fatalf("stale geometry reference accepted: %v", err)
}
}
func TestDocumentCaptureTreeIsIndependentAndInvalidated(t *testing.T) { func TestDocumentCaptureTreeIsIndependentAndInvalidated(t *testing.T) {
for _, eventType := range []string{ChildrenAddedEvent, DefunctStateEvent, DocumentReloadEvent, "shutdown"} { for _, eventType := range []string{ChildrenAddedEvent, DefunctStateEvent, DocumentReloadEvent, "shutdown"} {
t.Run(eventType, func(t *testing.T) { t.Run(eventType, func(t *testing.T) {
+103
View File
@@ -0,0 +1,103 @@
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package review
import (
"context"
"errors"
"reflect"
"testing"
"time"
"git.stormux.org/storm/cthulhu/internal/atspi"
)
type soundGeometryNavigator struct {
*fakeDocumentNavigator
geometry [2]atspi.SoundGeometry
err error
reads int
t *testing.T
}
func (navigator *soundGeometryNavigator) CaptureDocumentSoundGeometry(_ context.Context, object, link atspi.ObjectReference) ([2]atspi.SoundGeometry, error) {
navigator.reads++
select {
case placed := <-navigator.calls:
if placed.position != object || link.ID() != webID("/topic_link") {
navigator.t.Fatal("geometry refresh did not follow placement of the selected heading and link")
}
default:
navigator.t.Fatal("geometry read before scrolling the target")
}
return navigator.geometry, navigator.err
}
func TestDocumentSoundGeometryRefreshPreservesSemanticsAndPlacement(t *testing.T) {
oldGeometry := atspi.SoundGeometry{ObjectKnown: true, Object: atspi.Rectangle{X: 900, Y: 3000, Width: 50, Height: 15}}
freshGeometry := atspi.SoundGeometry{
ObjectKnown: true, ReferenceKnown: true,
Object: atspi.Rectangle{X: 100, Y: 300, Width: 50, Height: 15},
Reference: atspi.Rectangle{Width: 1000, Height: 600},
}
for _, name := range []string{"fresh", "unavailable", "refresh failed", "placement failed"} {
t.Run(name, func(t *testing.T) {
node := func(path, role string, children ...atspi.ObjectID) atspi.TreeNode {
snapshot := atspi.NewSnapshot(path, "Topic", role, "", "")
snapshot.BusName = webID(path).BusName
snapshot.SoundGeometry = oldGeometry
return atspi.TreeNode{Snapshot: snapshot, Children: children}
}
tree, err := atspi.NewAccessibleTree(webID("/doc"), []atspi.TreeNode{
node("/doc", "document web", webID("/heading")),
node("/heading", "heading", webID("/topic_link")),
node("/topic_link", "link"),
})
if err != nil {
t.Fatal(err)
}
cursor, _ := NewDocumentCursor(tree)
document, _ := tree.Root()
heading, _ := tree.Reference(webID("/heading"))
linkRef, _ := tree.Reference(webID("/topic_link"))
snapshot, _ := tree.Resolve(heading)
link, _ := tree.Resolve(linkRef)
navigator := &soundGeometryNavigator{fakeDocumentNavigator: newFakeDocumentNavigator(), t: t}
want := atspi.SoundGeometry{}
switch name {
case "fresh":
navigator.geometry = [2]atspi.SoundGeometry{freshGeometry, freshGeometry}
want = freshGeometry
case "refresh failed":
navigator.err = context.DeadlineExceeded
case "placement failed":
navigator.placementErr <- atspi.ErrStaleObject
want = oldGeometry
}
worker, _ := NewWorker(navigator, time.Second)
worker.SyncFocus(webID("/doc"))
result := worker.placeDocument(context.Background(), cursor, Result{
Moved: true, DocumentEpoch: worker.DocumentEpoch(), DocumentFocus: webID("/doc"),
DocumentPosition: DocumentPosition{Document: document, Object: heading, Snapshot: snapshot, LinkedHeading: &link},
})
if result.Err != nil || !errors.Is(result.DocumentSoundErr, navigator.err) {
t.Fatalf("sound refresh affected navigation: %+v", result)
}
if result.DocumentPosition.Snapshot.SoundGeometry != want || result.DocumentPosition.LinkedHeading.SoundGeometry != want {
t.Fatalf("geometry after %s = %+v", name, result.DocumentPosition)
}
if link.SoundGeometry != oldGeometry {
t.Fatal("refresh mutated the previously captured link snapshot")
}
cached, _ := tree.Resolve(heading)
if !reflect.DeepEqual(cached, snapshot) {
t.Fatal("refresh changed cached document semantics or geometry")
}
if name == "placement failed" && navigator.reads != 0 {
t.Fatal("failed caret placement still queried sound geometry")
}
})
}
}
+40
View File
@@ -488,5 +488,45 @@ func (worker *Worker) placeDocument(ctx context.Context, cursor *DocumentCursor,
deadline, cancel := context.WithTimeout(ctx, worker.captureTimeout) deadline, cancel := context.WithTimeout(ctx, worker.captureTimeout)
defer cancel() defer cancel()
result.CaretErr = navigator.SetDocumentPosition(deadline, result.DocumentPosition.Object, focus) result.CaretErr = navigator.SetDocumentPosition(deadline, result.DocumentPosition.Object, focus)
if result.CaretErr == nil && worker.CurrentDocumentResult(result) {
result = worker.refreshDocumentSoundGeometry(deadline, cursor, result)
}
return result
}
func (worker *Worker) refreshDocumentSoundGeometry(ctx context.Context, cursor *DocumentCursor, result Result) Result {
reader, ok := worker.nativeCapturerFor(result.DocumentFocus).(interface {
CaptureDocumentSoundGeometry(context.Context, atspi.ObjectReference, atspi.ObjectReference) ([2]atspi.SoundGeometry, error)
})
if !ok {
return result
}
// Geometry belongs to this presentation, not the document's initial viewport.
// Failed refreshes must not reuse an old position after scrolling or resizing.
result.DocumentPosition.Snapshot.SoundGeometry = atspi.SoundGeometry{}
link := atspi.ObjectReference{}
if previous := result.DocumentPosition.LinkedHeading; previous != nil {
copy := *previous
copy.SoundGeometry = atspi.SoundGeometry{}
result.DocumentPosition.LinkedHeading = &copy
if cursor == nil {
result.DocumentSoundErr = atspi.ErrStaleObject
return result
}
var err error
link, err = cursor.tree.Reference(copy.ID())
if err != nil {
result.DocumentSoundErr = err
return result
}
}
geometry, err := reader.CaptureDocumentSoundGeometry(ctx, result.DocumentPosition.Object, link)
result.DocumentSoundErr = err
if err == nil {
result.DocumentPosition.Snapshot.SoundGeometry = geometry[0]
if result.DocumentPosition.LinkedHeading != nil {
result.DocumentPosition.LinkedHeading.SoundGeometry = geometry[1]
}
}
return result return result
} }
+1
View File
@@ -77,6 +77,7 @@ type Result struct {
DocumentCaretContext []atspi.Snapshot DocumentCaretContext []atspi.Snapshot
DocumentBrailleLine *atspi.DocumentCaretText DocumentBrailleLine *atspi.DocumentCaretText
DocumentBrailleErr error DocumentBrailleErr error
DocumentSoundErr error
DocumentBackwards bool DocumentBackwards bool
DocumentLevel int DocumentLevel int
DocumentWrapped bool DocumentWrapped bool
+4
View File
@@ -11,4 +11,8 @@
<h4>Ordinary heading</h4> <h4>Ordinary heading</h4>
<h5>Choose: <a href="#first">First link</a> and <a href="#second">Second link</a></h5> <h5>Choose: <a href="#first">First link</a> and <a href="#second">Second link</a></h5>
<p id="first">First destination</p><p id="second">Second destination</p> <p id="first">First destination</p><p id="second">Second destination</p>
<h6 style="margin-top: 1200px"><a href="#sound-first">Offscreen first topic</a></h6>
<p id="sound-first">First sound destination</p>
<h6 style="margin-top: 1200px"><a href="#sound-second">Offscreen second topic</a></h6>
<p id="sound-second">Second sound destination</p>
</body></html> </body></html>