From ca42a2d1084ae631f414cbce5bf2daa6b522199a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Se=C3=A1n=20C=20McCord?= Date: Tue, 7 Oct 2025 19:41:12 -0400 Subject: [PATCH 1/3] Agenda enhancements Add PointsOfInterest as non-source points, for providing accessible points when using assistive devices. Adds auto-generation of tracks based on a pattern. --- agenda/agenda.go | 117 ++++++++++++++++++++++++++++++++++------------- 1 file changed, 84 insertions(+), 33 deletions(-) diff --git a/agenda/agenda.go b/agenda/agenda.go index 367e8bb..925e0b7 100644 --- a/agenda/agenda.go +++ b/agenda/agenda.go @@ -5,11 +5,12 @@ import ( "encoding/hex" "fmt" "os" + "slices" "strings" "time" "github.com/gofrs/uuid" - "gopkg.in/yaml.v3" + yaml "gopkg.in/yaml.v3" ) var fileFormats = []string{"mp3", "m4a", "webm"} @@ -22,8 +23,8 @@ func New(filename string) (*Agenda, error) { } a := new(Agenda) - err = yaml.Unmarshal(data, a) - if err != nil { + + if err := yaml.Unmarshal(data, a); err != nil { return nil, fmt.Errorf("failed to read YAML: %w", err) } @@ -104,12 +105,7 @@ func (a *Agenda) AllTracks() (out []*Track) { // Track already-seen audio files so that we do not list them twice var seen []string unseen := func(id string) bool { - for _, i := range seen { - if i == id { - return false - } - } - return true + return !slices.Contains(seen, id) } // Load Announcements first @@ -203,6 +199,9 @@ type Room struct { // played. Sources []*Source `json:"sources" yaml:"sources"` + // PointsOfInterest are non-source points in the room which are made available as targets to screen accessibility tooling. + PointsOfInterest []PointOfInterest `json:"pointsOfInterest" yaml:"pointsOfInterest"` + // RoomTracks is a list of audio tracks to be played in a room, sourced from // everywhere. This is generally exclusive with Sources. RoomTracks []*Track `json:"roomTracks" yaml:"roomTracks"` @@ -223,6 +222,12 @@ func (r *Room) generateIDs(a *Agenda) error { return err } + for _, p := range r.PointsOfInterest { + if err := p.generateID(); err != nil { + return fmt.Errorf("failed to generate ID for point of interest: %w", err) + } + } + for _, s := range r.Sources { err = s.generateIDs(a) if err != nil { @@ -281,12 +286,7 @@ func (r *Room) AllTracks() (out []*Track) { // Track already-seen audio files so that we do not list them twice var seen []string unseen := func(id string) bool { - for _, i := range seen { - if i == id { - return false - } - } - return true + return !slices.Contains(seen, id) } // Iterate Sources first @@ -313,13 +313,13 @@ func (r *Room) AllTracks() (out []*Track) { // Dimensions describe the dimensions of a space. type Dimensions struct { // Width is the left-to-right dimension. - Width float64 `json:"width" yaml:"width"` + Width float64 `json:"width" yaml:"width"` // Height is the up-to-down dimension (the height of a room). Height float64 `json:"height" yaml:"height"` // Depth is the forward-to-backward dimension. - Depth float64 `json:"depth" yaml:"depth"` + Depth float64 `json:"depth" yaml:"depth"` } // Surfaces describe the surface material of a room. @@ -334,9 +334,8 @@ type Surfaces struct { Up string `json:"up" yaml:"up"` } -// Source describes a unique audio sequence and location -type Source struct { - +// PointOfInterest describes a point of interest. +type PointOfInterest struct { // ID is the generated unique identifier ID string `json:"id" yaml:"-"` @@ -346,6 +345,26 @@ type Source struct { // Location indicates a specific 3-dimensional coordinate in the room from // which the audio of this source emanates Location Point `json:"location" yaml:"location"` +} + +func (p *PointOfInterest) generateID() error { + // If we don't have a name, generate one + if p.Name == "" { + p.Name = uuid.Must(uuid.NewV1()).String() + } + + p.ID = hashString(fmt.Sprintf("poi-%s", p.Name)) + + return nil +} + +// Source describes a unique audio sequence and location +type Source struct { + PointOfInterest `json:",inline" yaml:",inline"` + + // AutoTracks defines a pattern by which cue-track mappings are generated. + // NOTE: either RoomTracks or AutoTracks should be defined, but not both. + AutoTracks *AutoTracks `json:"autoTracks" yaml:"autoTracks"` // Tracks is the list of audio tracks which should be played upon reaching a // particular cue @@ -353,27 +372,45 @@ type Source struct { } func (s *Source) generateIDs(a *Agenda) error { - err := s.generateID() - if err != nil { + if err := s.generateID(); err != nil { return err } - for _, t := range s.Tracks { - if err = t.generateID(a); err != nil { - return err + if s.AutoTracks != nil { + var autoTracks []*Track + + for _, c := range a.Cues { + if slices.Contains(s.AutoTracks.IgnoreCues, c.Name) { + continue + } + + t := &Track{ + AudioFilePrefix: s.AutoTracks.Prefix + c.Name, + Cue: c.Name, + } + + if err := t.generateID(a); err != nil { + if s.AutoTracks.IgnoreMissing { + continue + } + + return fmt.Errorf("failed to generate track %s for cue %s in source %s: %w", + t.AudioFilePrefix, t.Cue, s.Name, err) + } + + autoTracks = append(autoTracks, t) } - } - return nil -} + s.Tracks = autoTracks -func (s *Source) generateID() error { - // If we don't have a name, generate one - if s.Name == "" { - s.Name = uuid.Must(uuid.NewV1()).String() + return nil } - s.ID = hashString(fmt.Sprintf("source-%s", s.Name)) + for _, t := range s.Tracks { + if err := t.generateID(a); err != nil { + return err + } + } return nil } @@ -460,6 +497,20 @@ func (t *Track) generateID(a *Agenda) error { return nil } +// AutoTracks defines a method by which cue-track mappings may be autogenerated. +type AutoTracks struct { + // Prefix defines a prefix which should be added to each auto-generated audio filename. + // Note that this does NOT presume a directory separation between the prefix and the filename. + // That is, a prefix of "alpha/beta" and a cue name of "gamma" would result in a file name "alpha/betagamma.webm". + Prefix string `json:"prefix" yaml:"prefix"` + + // IgnoreMissing indicates that any tracks which do not exist should simply be ignored. + IgnoreMissing bool `json:"ignoreMissing" yaml:"ignoreMissing"` + + // IgnoreCues provides a list of cues for which no track should be bound. + IgnoreCues []string +} + // Point is a 3-dimensional point in space type Point struct { X float64 `json:"x" yaml:"x"` From 3965b047f17d858577dd33df6204791d8187daf2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Se=C3=A1n=20C=20McCord?= Date: Tue, 7 Oct 2025 19:41:12 -0400 Subject: [PATCH 2/3] Agenda enhancements Add PointsOfInterest as non-source points, for providing accessible points when using assistive devices. Adds auto-generation of tracks based on a pattern. --- agenda/agenda.go | 117 ++++++++++++++++++++++++++++++++++------------- 1 file changed, 84 insertions(+), 33 deletions(-) diff --git a/agenda/agenda.go b/agenda/agenda.go index 367e8bb..ea052d1 100644 --- a/agenda/agenda.go +++ b/agenda/agenda.go @@ -5,11 +5,12 @@ import ( "encoding/hex" "fmt" "os" + "slices" "strings" "time" "github.com/gofrs/uuid" - "gopkg.in/yaml.v3" + yaml "gopkg.in/yaml.v3" ) var fileFormats = []string{"mp3", "m4a", "webm"} @@ -22,8 +23,8 @@ func New(filename string) (*Agenda, error) { } a := new(Agenda) - err = yaml.Unmarshal(data, a) - if err != nil { + + if err := yaml.Unmarshal(data, a); err != nil { return nil, fmt.Errorf("failed to read YAML: %w", err) } @@ -104,12 +105,7 @@ func (a *Agenda) AllTracks() (out []*Track) { // Track already-seen audio files so that we do not list them twice var seen []string unseen := func(id string) bool { - for _, i := range seen { - if i == id { - return false - } - } - return true + return !slices.Contains(seen, id) } // Load Announcements first @@ -203,6 +199,9 @@ type Room struct { // played. Sources []*Source `json:"sources" yaml:"sources"` + // PointsOfInterest are non-source points in the room which are made available as targets to screen accessibility tooling. + PointsOfInterest []*PointOfInterest `json:"pointsOfInterest" yaml:"pointsOfInterest"` + // RoomTracks is a list of audio tracks to be played in a room, sourced from // everywhere. This is generally exclusive with Sources. RoomTracks []*Track `json:"roomTracks" yaml:"roomTracks"` @@ -223,6 +222,12 @@ func (r *Room) generateIDs(a *Agenda) error { return err } + for _, p := range r.PointsOfInterest { + if err := p.generateID(); err != nil { + return fmt.Errorf("failed to generate ID for point of interest: %w", err) + } + } + for _, s := range r.Sources { err = s.generateIDs(a) if err != nil { @@ -281,12 +286,7 @@ func (r *Room) AllTracks() (out []*Track) { // Track already-seen audio files so that we do not list them twice var seen []string unseen := func(id string) bool { - for _, i := range seen { - if i == id { - return false - } - } - return true + return !slices.Contains(seen, id) } // Iterate Sources first @@ -313,13 +313,13 @@ func (r *Room) AllTracks() (out []*Track) { // Dimensions describe the dimensions of a space. type Dimensions struct { // Width is the left-to-right dimension. - Width float64 `json:"width" yaml:"width"` + Width float64 `json:"width" yaml:"width"` // Height is the up-to-down dimension (the height of a room). Height float64 `json:"height" yaml:"height"` // Depth is the forward-to-backward dimension. - Depth float64 `json:"depth" yaml:"depth"` + Depth float64 `json:"depth" yaml:"depth"` } // Surfaces describe the surface material of a room. @@ -334,9 +334,8 @@ type Surfaces struct { Up string `json:"up" yaml:"up"` } -// Source describes a unique audio sequence and location -type Source struct { - +// PointOfInterest describes a point of interest. +type PointOfInterest struct { // ID is the generated unique identifier ID string `json:"id" yaml:"-"` @@ -346,6 +345,26 @@ type Source struct { // Location indicates a specific 3-dimensional coordinate in the room from // which the audio of this source emanates Location Point `json:"location" yaml:"location"` +} + +func (p *PointOfInterest) generateID() error { + // If we don't have a name, generate one + if p.Name == "" { + p.Name = uuid.Must(uuid.NewV1()).String() + } + + p.ID = hashString(fmt.Sprintf("poi-%s", p.Name)) + + return nil +} + +// Source describes a unique audio sequence and location +type Source struct { + PointOfInterest `json:",inline" yaml:",inline"` + + // AutoTracks defines a pattern by which cue-track mappings are generated. + // NOTE: either RoomTracks or AutoTracks should be defined, but not both. + AutoTracks *AutoTracks `json:"autoTracks" yaml:"autoTracks"` // Tracks is the list of audio tracks which should be played upon reaching a // particular cue @@ -353,27 +372,45 @@ type Source struct { } func (s *Source) generateIDs(a *Agenda) error { - err := s.generateID() - if err != nil { + if err := s.generateID(); err != nil { return err } - for _, t := range s.Tracks { - if err = t.generateID(a); err != nil { - return err + if s.AutoTracks != nil { + var autoTracks []*Track + + for _, c := range a.Cues { + if slices.Contains(s.AutoTracks.IgnoreCues, c.Name) { + continue + } + + t := &Track{ + AudioFilePrefix: s.AutoTracks.Prefix + c.Name, + Cue: c.Name, + } + + if err := t.generateID(a); err != nil { + if s.AutoTracks.IgnoreMissing { + continue + } + + return fmt.Errorf("failed to generate track %s for cue %s in source %s: %w", + t.AudioFilePrefix, t.Cue, s.Name, err) + } + + autoTracks = append(autoTracks, t) } - } - return nil -} + s.Tracks = autoTracks -func (s *Source) generateID() error { - // If we don't have a name, generate one - if s.Name == "" { - s.Name = uuid.Must(uuid.NewV1()).String() + return nil } - s.ID = hashString(fmt.Sprintf("source-%s", s.Name)) + for _, t := range s.Tracks { + if err := t.generateID(a); err != nil { + return err + } + } return nil } @@ -460,6 +497,20 @@ func (t *Track) generateID(a *Agenda) error { return nil } +// AutoTracks defines a method by which cue-track mappings may be autogenerated. +type AutoTracks struct { + // Prefix defines a prefix which should be added to each auto-generated audio filename. + // Note that this does NOT presume a directory separation between the prefix and the filename. + // That is, a prefix of "alpha/beta" and a cue name of "gamma" would result in a file name "alpha/betagamma.webm". + Prefix string `json:"prefix" yaml:"prefix"` + + // IgnoreMissing indicates that any tracks which do not exist should simply be ignored. + IgnoreMissing bool `json:"ignoreMissing" yaml:"ignoreMissing"` + + // IgnoreCues provides a list of cues for which no track should be bound. + IgnoreCues []string +} + // Point is a 3-dimensional point in space type Point struct { X float64 `json:"x" yaml:"x"` From 6e8782c6522a634bc339b4efc77bcd9e938cd623 Mon Sep 17 00:00:00 2001 From: Colin Clark Date: Tue, 7 Oct 2025 21:40:58 -0400 Subject: [PATCH 3/3] Resolves gh-25: Renders points of interest in the spatial room. --- src/room.js | 31 +++++++++++++++++++++++++++---- 1 file changed, 27 insertions(+), 4 deletions(-) diff --git a/src/room.js b/src/room.js index 91bb44a..f7f9941 100644 --- a/src/room.js +++ b/src/room.js @@ -240,15 +240,38 @@ export class SpatialRoom extends EventTarget { .attr("text-anchor", "middle") .attr("class", "sourceText") .attr("fill", "grey") - .text(function(d) { return d.name }) + .text(function(d) { + return d.name + }) .merge(labels) - .attr("x", function(d) { return self.scaleX(d.location.x + self.data.dimensions.width/2) }) - .attr("y", function(d) { return self.scaleY(self.data.dimensions.depth/2-d.location.z) }) + .attr("x", function(d) { + return self.scaleX(d.location.x + self.data.dimensions.width/2) + }) + .attr("y", function(d) { + return self.scaleY(self.data.dimensions.depth/2-d.location.z) + }) .attr("dy", "2em") + + // Draw points of interest, which are hidden text labels only. + let pois = self.svg.selectAll("text.poi").data(self.data.pointsOfInterest) + pois.enter().append("text") + .attr("text-anchor", "middle") + .attr("class", "poi") + .text(function(d) { + return d.name + }) + .merge(pois) + .attr("x", function(d) { + return self.scaleX( + d.location.x + self.data.dimensions.width / 2) + }) + .attr("y", function(d) { + return self.scaleY( + self.data.dimensions.depth/2-d.location.z) + }) } } - function loadAudio(room) { loadAudioResonance(room) }