HarmonyOS 7 空间音频导览连载02(共08篇):声源与导览节点建模【共创稿事节】

有了空间音频状态查询,室内导览的下一步并不是立即调用播放器,而是先定义“哪个展品对应哪段声音”。在小型展馆里,一件展品可能有语音讲解、背景环境声和方向提示三类资源。如果仅靠列表下标关联音频文件,当展区调整顺序或临时关闭某个节点时,页面就容易播错内容。更稳妥的方式是让每个节点拥有固定身份、位置、展区、音源等级和资源路径,再让路线与播放流程围绕这个数据模型工作。

空间音频与室内展馆导览节点设计

一、把展馆坐标与真实地理坐标分离

本系列选取一个室内展览馆作为连续工程场景。入口、青铜器展区、书画长廊和数字展厅组成基本路线。这里使用展馆平面坐标,单位为米,x 向右、y 向展馆纵深,起点是入口左下方。它用于路线距离估算和界面节点排布;用户所在位置由页面选择一个入口或展区作为起始点,之后可以接入室内定位数据,但不在模型层预设具体定位硬件。

音源同样需要独立分级。STEREO 表示普通立体声文件;MULTICHANNEL 表示多声道;OBJECT_AUDIO 表示包含对象化信息的资源;VOICE_GUIDE 表示以语音可理解性为优先的讲解音频。这个枚举属于业务资源描述,无需直接使用系统声场渲染接口。对于 Audio Vivid 资源,还需由后续播放器链路处理相应编码与元数据。

字段作用设计要点
id稳定节点标识展区顺序变化时保持不变
position展馆平面位置使用统一坐标系和米单位
voiceAsset讲解资源名称与应用 rawfile 文件名一致
sourceTier音源类别与设备支持状态独立
accessible是否进入推荐路线展区暂停开放时仍保留原始节点
priority推荐优先级相同距离下采用业务规则排序

二、编写类型明确的节点目录

目录类不持有 ArkUI 页面对象,也不创建播放器。节点数据从集中数组读取,页面可以按展区和距离查询;后续增加音频缓存、远端内容更新时,也可以保持 GuideNode 的字段语义不变。导览资源名称采用普通字符串,待第三篇创建音频播放对象时再与 resources/rawfile 对接。

// entry/src/main/ets/model/GuideNode.ets
export interface Point2D {
  x: number;
  y: number;
}

export enum SourceTier {
  VOICE_GUIDE = 'voice-guide',
  STEREO = 'stereo',
  MULTICHANNEL = 'multichannel',
  OBJECT_AUDIO = 'object-audio'
}

export interface GuideNode {
  id: string;
  title: string;
  hall: string;
  summary: string;
  position: Point2D;
  voiceAsset: string;
  sourceTier: SourceTier;
  accessible: boolean;
  priority: number;
}

export interface NearbyNode {
  node: GuideNode;
  distanceM: number;
}
// entry/src/main/ets/model/GuideCatalog.ets
import { GuideNode, SourceTier } from './GuideNode';

export const GUIDE_NODES: GuideNode[] = [
  {
    id: 'entrance', title: '序厅·城市记忆', hall: '一层序厅',
    summary: '了解展馆主题与参观路线',
    position: { x: 3, y: 3 }, voiceAsset: 'entrance.mp3',
    sourceTier: SourceTier.VOICE_GUIDE, accessible: true, priority: 4
  },
  {
    id: 'bronze', title: '青铜器展区', hall: '一层东区',
    summary: '从器型与铭文认识古代工艺',
    position: { x: 15, y: 8 }, voiceAsset: 'bronze.mp3',
    sourceTier: SourceTier.STEREO, accessible: true, priority: 3
  },
  {
    id: 'calligraphy', title: '书画长廊', hall: '一层北区',
    summary: '欣赏笔墨与留白的层次',
    position: { x: 24, y: 18 }, voiceAsset: 'painting.mp3',
    sourceTier: SourceTier.VOICE_GUIDE, accessible: true, priority: 2
  },
  {
    id: 'digital', title: '数字展厅', hall: '一层西区',
    summary: '探索互动影像与空间声音',
    position: { x: 8, y: 20 }, voiceAsset: 'digital.m4a',
    sourceTier: SourceTier.OBJECT_AUDIO, accessible: true, priority: 1
  }
];

id 不使用列表下标,也不把展品标题作为标识:标题可能因为策展文案调整发生变化,节点 ID 却必须稳定。voiceAsset 与 sourceTier 并非自动一致,因此输入阶段需要校验;例如普通 MP3 文件的类别应按实际资源属性填写,而不能仅依据文件扩展名推断对象音频能力。

三、用纯计算函数处理距离与排序

展馆节点排序不应耦合到播放回调。距离计算提取为纯函数,对入口、节点坐标和可访问状态分别处理。对于面积不大的展厅,二维直线距离适合做粗略推荐;走廊和隔断较多时,路线长度应由展馆拓扑图计算,不应继续把欧氏距离当成可行走距离。

// entry/src/main/ets/services/GuidePlanner.ets
import { GuideNode, NearbyNode, Point2D } from '../model/GuideNode';
import { GUIDE_NODES } from '../model/GuideCatalog';

export function distanceM(a: Point2D, b: Point2D): number {
  const dx: number = a.x - b.x;
  const dy: number = a.y - b.y;
  return Math.sqrt(dx * dx + dy * dy);
}

export class GuidePlanner {
  private nodes: GuideNode[] = GUIDE_NODES;

  public findById(id: string): GuideNode | undefined {
    return this.nodes.find((node: GuideNode): boolean => node.id === id);
  }

  public recommend(from: Point2D, excludedId: string = ''): NearbyNode[] {
    const result: NearbyNode[] = [];
    for (const node of this.nodes) {
      if (!node.accessible || node.id === excludedId) {
        continue;
      }
      result.push({ node: node, distanceM: distanceM(from, node.position) });
    }
    result.sort((a: NearbyNode, b: NearbyNode): number => {
      const diff: number = a.distanceM - b.distanceM;
      return Math.abs(diff) > 0.01 ? diff : b.node.priority - a.node.priority;
    });
    return result;
  }

  public all(): GuideNode[] {
    return this.nodes.filter((node: GuideNode): boolean => node.accessible);
  }
}

排序规则优先选距离近的节点,距离差小于 1 厘米时再比较优先级。这里使用 Math.sqrt,前提是坐标使用同一种长度单位。若日后接入楼层间路径,需要把 hall 扩展为楼层 ID 与有向连通边,不能把上下楼移动压缩成平面直线。

四、在导览页面串联选中节点与推荐列表

第一篇的 SpatialStatusService 继续负责设备能力和开关,本篇新增的 GuidePlanner 只处理展品数据。页面通过 @State 保存当前节点 ID、标题及推荐结果;选择其他展品时更新起点与列表,让用户清楚“从哪里出发,接下来能去哪里”。推荐距离属于展馆坐标计算值,页面用“平面距离”标注,防止与真实步行路线混淆。

开发环境中的导览节点数据结构与节点列表

// entry/src/main/ets/pages/GuidePage.ets
import { GuideNode, NearbyNode } from '../model/GuideNode';
import { GuidePlanner } from '../services/GuidePlanner';

@Entry
@Component
struct GuidePage {
  private planner: GuidePlanner = new GuidePlanner();
  @State selectedId: string = 'entrance';
  @State selectedTitle: string = '序厅·城市记忆';
  @State nearby: NearbyNode[] = [];

  aboutToAppear(): void {
    this.choose('entrance');
  }

  private choose(id: string): void {
    const node: GuideNode | undefined = this.planner.findById(id);
    if (!node || !node.accessible) {
      return;
    }
    this.selectedId = node.id;
    this.selectedTitle = node.title;
    this.nearby = this.planner.recommend(node.position, node.id);
  }

  build() {
    Scroll() {
      Column({ space: 16 }) {
        Text('室内导览').fontSize(30).fontWeight(FontWeight.Bold)
          .width('100%')
        Column({ space: 8 }) {
          Text('当前位置(页面选择)').fontColor('#64748B').fontSize(14)
          Text(this.selectedTitle).fontSize(24).fontWeight(FontWeight.Bold)
          Text('推荐下一处展品').fontColor('#64748B').fontSize(14)
        }
        .padding(20).backgroundColor('#E7F2FF')
        .borderRadius(18).width('100%')
        ForEach(this.nearby, (entry: NearbyNode) => {
          Row({ space: 12 }) {
            Column({ space: 6 }) {
              Text(entry.node.title).fontSize(18)
                .fontWeight(FontWeight.Medium)
              Text(entry.node.summary).fontColor('#64748B').fontSize(13)
              Text(`平面距离约 ${entry.distanceM.toFixed(1)} 米`)
                .fontColor('#2563EB').fontSize(13)
            }.alignItems(HorizontalAlign.Start).layoutWeight(1)
            Button('选择')
              .onClick((): void => { this.choose(entry.node.id); })
          }
          .padding(16).borderRadius(16).backgroundColor(Color.White)
          .width('100%')
        }, (entry: NearbyNode): string => entry.node.id)
      }.width('100%').padding(20)
    }.backgroundColor('#F4F7FB')
  }
}

展馆节点、平面路线和推荐距离的手机运行界面

五、处理边界场景:关闭节点、同名展品和路线重排

节点模型要先解决几个真实的运营问题。其一,展品临时下架时,保留节点记录,但将 accessible 改为 false;收藏页可以继续显示原来的展品名称,推荐列表则通过过滤条件自然排除它。其二,不同楼层可能各有“序厅”或“服务台”,这时用文字标题查找会发生歧义,应始终以 id 作为查询主键。其三,展区临时调整,推荐顺序可能改变,但任何音频资源都只能通过节点关联关系读取,不能把 GUIDE_NODES[2] 当成固定展品。

路线页面的选中状态也需要有明确的优先级。用户手动选择节点时,页面位置从选中节点的平面坐标读取;后续接入定位服务后,应区分“定位建议的节点”和“用户明确选中的节点”。前者可以随新坐标更新,后者不应因为一次较小的位置漂移而自动跳转。可以在会话层添加 selectionMode 字段,取值为“手动选择”与“位置推荐”,并记录上一次明确操作时间。这项规则与声音播放是否开启无关,应先在导览状态机中约定。

数据量增加后,不建议每次滚动列表都重新对全部展品计算距离。展品坐标基本固定,可以按楼层预分组,并在用户位置有实际变化时才更新候选列表。若展馆存在封闭通道和楼梯,应建立由走廊交点、楼梯入口、展品节点组成的连通图,再根据可通行边计算路线成本。此时现有 distanceM 仍可以作为列表的粗筛距离,但“下一站”最终次序应以可达路径为准。

六、给资源更新与播放流程留出接口

这里的节点目录是本地静态数据,适合先把类型与页面交互固定下来。生产工程可以从 JSON、数据库或内容接口读取资源,但应在写入目录前检查 ID 唯一、坐标为有限数字、文件名非空、优先级在约定范围内、音源类别与实际媒体资源相符。展区临时关闭时只修改 accessible,不要删除 ID 后立即复用,让收藏记录和播放历史仍能准确指向原展品。

播放接入时,可以用 selectedId 找到 voiceAsset,交由专门的播放服务加载;再联合第一篇的 SpatialSnapshot 选择资源提示等级。如果输出设备仅具备普通立体声能力,仍可播放常规讲解,不需要把每个节点绑定到特定耳机。对于对象化音源,渲染是否具备空间效果还受编码、资源内容和播放链路影响,页面应按真实资源类型呈现。

定位与导览在工程上也要分离:定位服务提供展馆坐标,路线层消化坐标并计算可达节点,音频层负责资源加载,ArkUI 负责最终呈现。这样新增楼层、增设展区或替换讲解资源时,影响范围都能保持局部可控。

这篇把“展品、平面位置、音源资源、路线推荐”组织成了同一份数据契约。下一篇可以据此实现音频播放会话和状态管理,不必让播放器直接理解展馆布局。相关参考:AudioManager API 参考、HarmonyOS 空间计算能力、开发者文档中心。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐