尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

HarmonyOS 弦乐调音器开发实战 04:Flutter 页面如何通过 ArkTS 插件接入系统音频

HarmonyOS 弦乐调音器开发实战 04:Flutter 页面如何通过 ArkTS 插件接入系统音频 这篇文章只讨论项目中的 Flutter 1.0.8 工程不把它和前面几篇使用的原生 ArkTS 1.0.7 工程混在一起。页面由 Flutter 绘制但隐私同意、麦克风权限、PCM 采集、音高检测和参考音播放仍然要落到 HarmonyOS 系统能力上。项目没有把系统 API 零散地塞进每一个页面而是用一个 Dart 门面类和一个 ArkTS 插件建立边界离散命令走MethodChannel连续音频结果走EventChannel。本文展示的通道名、方法名、采样参数和算法阈值都来自当前源码。本轮确实完成了 Flutter 静态分析、一个页面冒烟测试和 release HAP 构建但是当前没有 HDC 设备因此没有对新生成的最终包执行安装、启动、麦克风采集或真实乐器回归。下面会把“源码存在”“构建通过”和“实机验证”分别说明。先明确本文对应的是 Flutter 1.0.8 工程这个目录中同时保留了两套实现。根工程是 HarmonyOS Stage 模型的 ArkTS/ArkUI 应用版本为 1.0.7string_tuner_flutter才是本文分析的 Flutter/OHOS 工程版本为 1.0.8。Flutter 工程的页面源码位于string_tuner_flutter/libHarmonyOS 插件位于string_tuner_flutter/ohos/entry/src/main/ets/plugins。区分这两条工程线十分重要。2026-05-14 的早期截图属于原生 ArkTS 实现2026-07-05 的截图属于 Flutter 调试运行。两批截图都早于本轮重新生成的 release HAP不能拿旧截图证明最新包已经回归。本文使用 Flutter 截图是为了说明当时页面和系统权限弹窗曾经运行而不是把它包装成这次最终包的验收回执。Flutter 页面已经包含首页、调音、历史、我的和曲库等入口。曲库页能够说明页面路由和数据渲染已经落地但静态截图不能证明参考音真的从扬声器播放也不能证明音色、时延或音量符合预期。为什么 Flutter 页面不能直接承担系统音频生命周期Flutter 擅长组织跨平台界面和状态但 HarmonyOS 的隐私托管、运行时权限、AudioCapturer、AudioRenderer与Preferences都属于平台侧能力。如果页面直接拼接这些能力会出现三个问题第一Dart 页面必须知道平台 API 的细节第二麦克风的创建、停止和释放容易散落到多个生命周期回调第三连续 PCM 数据如果按一次次方法调用返回会产生不必要的请求与响应开销。当前项目把职责切成三层Flutter 页面决定“现在是否需要实时音频”并展示TunerFrameNativeTuner统一封装 Dart 到原生的调用和异常回退StringTunerPlugin持有 Ability 上下文、系统音频对象和事件出口。这种分层并不意味着所有调音逻辑都放在 ArkTS。插件负责把 PCM 变成频率、清晰度、波形摘要和“频谱”摘要Flutter 层再根据当前乐器、A4 参考频率和自动/手动模式选择目标弦并计算音分与会话统计。边界清楚以后页面不需要持有AudioCapturer插件也不需要理解具体的卡片布局。桥接总览一条命令通道一条事件通道项目约定了两个完全一致的通道名string_tuner/native方法通道处理隐私确认、开始/停止采音、屏幕常亮、设置与历史持久化、参考音播放string_tuner/audio_events事件通道持续发送频率、清晰度、波形和摘要数组。方法通道适合有明确返回值的离散动作事件通道适合原生侧主动推送的连续状态。页面只在需要实时音频时订阅事件停止时取消订阅并通知插件释放采音资源。项目相对路径string_tuner_flutter/lib/native_tuner.dartclass NativeTuner { NativeTuner._(); static const MethodChannel _method MethodChannel(string_tuner/native); static const EventChannel _events EventChannel(string_tuner/audio_events);项目中的启动方法保持原始返回结构项目相对路径string_tuner_flutter/lib/native_tuner.dartstatic FutureMicStartResult startAudio() async { try { final Mapdynamic, dynamic? value await _method .invokeMapMethoddynamic, dynamic(startAudio, String, Object{ debugPrivacyFallback: kDebugMode, }); return MicStartResult.fromMap(value); } on MissingPluginException { return const MicStartResult(granted: false, wasAlreadyGranted: false); } on PlatformException { return const MicStartResult(granted: false, wasAlreadyGranted: false); } }连续事件则由同一文件中的广播流转换成TunerFrame项目相对路径string_tuner_flutter/lib/native_tuner.dartstatic StreamTunerFrame audioFrames() { return _events.receiveBroadcastStream().map((Object? event) { if (event is Map) { return TunerFrame.fromMap(event); } return TunerFrame.empty(); }); }这里不能把不同调用的回退能力混为一谈。NativeTuner的部分MethodChannel辅助方法会捕获平台调用异常并返回“未授权”等默认值但上面的audioFrames()没有捕获MissingPluginExceptionTunerFrame.empty()只处理事件不是Map的情况。当前页面冒烟测试之所以没有订阅失败是因为隐私同步回退为未接受后没有启动实时音频流它既没有测试EventChannel缺失时的兜底也没有验证原生插件。Dart 侧只暴露业务需要的类型NativeTuner没有把平台侧的复杂对象直接交给页面。隐私结果被转换成PrivacyConsentResult麦克风启动结果被转换成MicStartResult音频事件被转换成TunerFrame。页面关心的是accepted、granted、frequency和clarity而不是平台权限返回数组或ArrayBuffer。这种类型收口还能避免一个常见错误把“已经授权”和“本次刚刚授权”混为一谈。插件返回wasAlreadyGrantedFlutter 页面据此决定是否显示“权限已经授权”的提示无论哪一种情况真正能否开始采音仍由granted决定。项目相对路径string_tuner_flutter/lib/native_tuner.dartclass MicStartResult { const MicStartResult({ required this.granted, required this.wasAlreadyGranted, }); factory MicStartResult.fromMap(Mapdynamic, dynamic? value) { return MicStartResult( granted: value?[granted] true, wasAlreadyGranted: value?[wasAlreadyGranted] true, ); } final bool granted; final bool wasAlreadyGranted; }ArkTS 插件如何注册并分发方法调用StringTunerPlugin同时实现FlutterPlugin、AbilityAware、MethodCallHandler和StreamHandler。它在绑定 Flutter Engine 时创建两个通道在绑定 Ability 时取得UIAbilityContext。后者是隐私接口、权限请求和窗口控制的必要上下文。项目相对路径string_tuner_flutter/ohos/entry/src/main/ets/plugins/StringTunerPlugin.etsonMethodCall(call: MethodCall, result: MethodResult): void { switch (call.method) { case ensurePrivacyConsent: this.ensurePrivacyConsent(call, result); return; case startAudio: this.startAudio(call, result); return; case stopAudio: this.stopAudio().then(() { result.success(null); }).catch((err: Error) { result.error(STOP_AUDIO_FAILED, err.message, null); }); return; case setKeepScreenOn: this.setKeepScreenOn(call, result); return; case loadAppState: this.loadAppState(result); return; case saveSettings: this.saveSettings(call, result); return; case saveSessions: this.saveSessions(call, result); return; case playReferenceTone: this.playReferenceTone(call, result); return; case stopReferenceTone: this.stopReferenceTone().then(() { result.success(null); }).catch((err: Error) { result.error(STOP_REFERENCE_FAILED, err.message, null); }); return; default: result.notImplemented(); } }两个通道的创建代码也是当前文件中的原始实现项目相对路径string_tuner_flutter/ohos/entry/src/main/ets/plugins/StringTunerPlugin.etsprivate attachToEngine(messenger: BinaryMessenger): void { this.methodChannel new MethodChannel(messenger, METHOD_CHANNEL); this.methodChannel.setMethodCallHandler(this); this.eventChannel new EventChannel(messenger, EVENT_CHANNEL); this.eventChannel.setStreamHandler(this); }完整源码中还分发setKeepScreenOn、loadAppState、saveSettings、saveSessions、playReferenceTone与stopReferenceTone。本文只截取音频入口相关分支没有把不存在的方法改写进示例。订阅和取消同样由插件管理。onListen保存EventSink并先发一帧当前状态onCancel清空出口并停止采音。Ability 或 Engine 分离时也会停止AudioCapturer和参考音避免页面离开后继续占用麦克风或扬声器。隐私同意必须先于麦克风授权平台托管隐私与运行时麦克风权限是两个步骤。ensurePrivacyConsent()先复用内存中的privacyAccepted再读取平台托管结果只有仍未接受时才调用privacyManager.requestAppPrivacyConsent()调试参数还允许走回退路径。随后startAudio()再检查调试回退或既有托管结果通过隐私门后才请求ohos.permission.MICROPHONE。如果没有 Ability 上下文、隐私未接受或权限被拒绝方法都会返回 false 状态不会继续创建采音器。项目相对路径string_tuner_flutter/ohos/entry/src/main/ets/plugins/StringTunerPlugin.etsprivate async startAudio(call: MethodCall, result: MethodResult): Promisevoid { const context this.abilityContext; if (context null) { result.success({ granted: false, wasAlreadyGranted: false }); return; } if (call.argument(debugPrivacyFallback) true) { this.privacyAccepted true; } if (!this.privacyAccepted) { this.privacyAccepted this.resolvePrivacyAccepted(this.getHostedPrivacyResult(), false); } if (!this.privacyAccepted) { result.success({ granted: false, wasAlreadyGranted: false }); return; } const permissionResult await this.requestMicPermission(context); if (!permissionResult.granted) { result.success({ granted: false, wasAlreadyGranted: permissionResult.wasAlreadyGranted, }); return; }这里必须说明调试边界Dart 侧把kDebugMode作为debugPrivacyFallback传入调试构建允许代码绕过托管隐私结果release 构建中该值为 false。因此2026-07-05 的 Flutter 调试截图能够证明系统麦克风弹窗曾显示但不能证明 release 包的托管隐私顺序已经完成实机验证。这张图片只证明调试运行时出现过系统麦克风权限弹窗画面中没有平台托管隐私页面或调用时序因此不能用它证明“隐私请求一定先于麦克风弹窗”。真实顺序只能由上述当前源码解释本轮还缺 release 包的实机流程录像或日志。创建 AudioCapturer44.1 kHz、16 bit、单声道通过隐私和权限门控以后插件才创建AudioCapturer。当前参数是固定的 44.1 kHz、单声道、S16LE、RAW 编码输入源为麦克风。设置页中即使保存了其他采样率字段也不能据此宣称采音参数已经动态切换真正生效的参数应以这里的AudioCapturerOptions为准。项目相对路径string_tuner_flutter/ohos/entry/src/main/ets/plugins/StringTunerPlugin.etsconst options: audio.AudioCapturerOptions { streamInfo: { samplingRate: audio.AudioSamplingRate.SAMPLE_RATE_44100, channels: audio.AudioChannel.CHANNEL_1, sampleFormat: audio.AudioSampleFormat.SAMPLE_FORMAT_S16LE, encodingType: audio.AudioEncodingType.ENCODING_TYPE_RAW, }, capturerInfo: { source: audio.SourceType.SOURCE_TYPE_MIC, capturerFlags: 0, }, }; try { this.capturer await audio.createAudioCapturer(options); this.capturer.on(readData, (buffer: ArrayBuffer) { this.processBuffer(buffer); }); await this.capturer.start(); this.isRunning true; result.success({ granted: true, wasAlreadyGranted: permissionResult.wasAlreadyGranted, });readData回调只把原始缓冲区送进processBuffer不直接操作 Flutter UI。停止时则注销回调、停止并释放 capturer再把频率、清晰度和数组恢复为零。这样 Flutter 页面再次订阅时不会先看到上一轮会话的陈旧数据。从 PCM 到 TunerFrameNSDF 在插件内完成S16LE 样本先除以 32768 转成约 -1 到 1 的浮点值并累积到 4096 个样本。达到窗口长度以后插件取最近一窗执行detectPitch再保留半窗作为下一次分析的重叠数据。音高范围限制为 401500 Hz能量阈值为 0.0008NSDF 峰值与清晰度阈值都是 0.65。项目相对路径string_tuner_flutter/ohos/entry/src/main/ets/plugins/StringTunerPlugin.etsprivate processBuffer(buffer: ArrayBuffer): void { const int16View new Int16Array(buffer); const count: number int16View.length; for (let i 0; i count; i) { this.accumBuffer.push(int16View[i] / 32768.0); } if (this.accumBuffer.length ACCUM_TARGET) { if (this.accumBuffer.length 128) { this.latestWaveform this.computeWaveform(this.accumBuffer); } return; } const startIdx: number this.accumBuffer.length - ACCUM_TARGET; const samples: number[] this.accumBuffer.slice(startIdx); const pitch this.detectPitch(samples); this.latestFrequency this.filterFrequency(pitch.frequency, pitch.clarity); this.latestClarity pitch.clarity; this.latestWaveform this.computeWaveform(samples); this.spectrumFrameSkip this.spectrumFrameSkip 1; if (this.spectrumFrameSkip 3) { this.latestSpectrum this.computeSpectrum(samples); this.spectrumFrameSkip 0; } this.accumBuffer this.accumBuffer.slice(this.accumBuffer.length - Math.floor(ACCUM_TARGET / 2)); this.emitFrameSkip this.emitFrameSkip 1; if (this.emitFrameSkip 2) { this.emitFrameSkip 0; this.emitFrame(); } }detectPitch计算不同延迟tau下的归一化相关值找到第一个达到阈值的局部峰再用相邻三个点做抛物线插值得到refinedTau最后用SAMPLE_RATE / refinedTau换算频率。这是 NSDF 路径不是 FFT、YIN 或 pYIN。另外源码中的computeSpectrum也不是频域 FFT。Flutter 插件把最近最多 1024 个样本分成 28 个时间片根据平均绝对幅值和峰值生成 01 的柱形摘要。它可以用于动态视觉反馈但文章不能把它描述成严格的频谱分析。为了控制跨端事件频率摘要每三次分析更新一次事件每两次分析发送一次。emitFrame的载荷保持简单项目相对路径string_tuner_flutter/ohos/entry/src/main/ets/plugins/StringTunerPlugin.etsprivate emitFrame(): void { this.eventSink?.success({ frequency: this.latestFrequency, clarity: this.latestClarity, waveform: this.latestWaveform, spectrum: this.latestSpectrum, }); }Flutter 页面只消费帧并管理订阅页面启动实时音频时先订阅NativeTuner.audioFrames()再调用startAudio()。收到帧后如果页面仍挂载且当前场景确实需要实时音频才更新状态并记录会话。停止时先取消订阅再停止原生采音并关闭屏幕常亮。项目相对路径string_tuner_flutter/lib/main.dartFuturevoid _startAudio() async { if (!_privacyReady || !_privacyAccepted || _audioStarting) { return; } _audioStarting true; _audioSub ?? NativeTuner.audioFrames().listen((TunerFrame frame) { if (!mounted || !_wantsLiveAudio) { return; } _recordTuningFrame(frame); setState(() { _frame frame; }); }); final MicStartResult result await NativeTuner.startAudio(); _audioStarting false; if (!mounted) { return; } setState(() { _micGranted result.granted; if (!result.granted) { _frame TunerFrame.empty(); } }); _syncKeepScreenOn(); if (result.granted result.wasAlreadyGranted) { _showTip(麦克风权限已授权应用正在使用麦克风进行实时音高校准。); } if (!result.granted) { _showTip(未获得麦克风权限无法进行实时调音。请在系统设置中开启麦克风权限。); } } Futurevoid _stopAudio() async { await _audioSub?.cancel(); _audioSub null; await NativeTuner.stopAudio(); await NativeTuner.setKeepScreenOn(false); }插件输出的是检测频率和清晰度自动选弦与音分换算仍在 Dart 层进行。当前页面只在调音标签、频率大于零且清晰度不低于 0.65 时统计有效帧。这种设计使原生音频层可以保持与具体乐器无关但也意味着“插件能出频率”和“整套自动选弦准确”是两个需要分别验证的结论。本轮构建与测试实际验证了什么本轮对 Flutter 1.0.8 工程执行了以下命令flutter analyze flutter test flutter build hap --release结果分别是flutter analyze没有发现问题flutter test1 个页面冒烟测试通过测试只验证首页能显示“弦乐调音器”和“自动”文本flutter build hap --release成功生成新的 signed 命名 HAP。新 HAP 大小为25,692,787 bytesSHA-256 为CF417384958952EF6D0B9B0D6E3090B87944B4C419E7C60D292964312B90C8B2包内module.json显示版本 1.0.8、debug:false、buildMode:release、compileSdkVersion:6.1.0.105目标和最低 API 都为 22并声明 INTERNET 与 MICROPHONE 权限。这些事实能够证明静态分析、页面冒烟测试、release 构建和包元数据它们不能替代安装、启动、采音、扬声器或音高精度验证。当前没有 HDC 设备最终包尚未实机回归本轮查询hdc list targets的结果为[Empty]。因此没有执行新 HAP 的安装、Ability 启动、隐私同意流程、麦克风授权、实时频率输出、参考音播放或页面性能回归。也没有外部标准信号源和测量记录不能宣称已经达到 ±2 cents、固定时延、60 FPS、某个功耗或温升指标。2026-07-05 的 Flutter 调试截图可以作为历史运行材料它们证明当时页面曾显示、系统麦克风权限弹窗曾出现、曲库与设置界面曾渲染。但这些截图早于本轮 HAP也处于允许debugPrivacyFallback的调试路径不能升级为 release 隐私链路或最终包验收证据。历史 AGC 截图同样要限定时间。当时页面显示 1.0.7 已上架而 1.0.8 仍处于“准备提交/未提交”状态截图中的上传包也早于后续 reviewfix 包。它既不能证明 1.0.8 已发布也不能代表今天的线上状态。本文因此只标记为本地待发布稿不填写公开地址。这套桥接方式可以复用但验证边界不能省略从工程结构看这套插件方案有四个值得复用的点用MethodChannel处理有明确结果的离散命令用EventChannel推送连续帧由实现AbilityAware的 ArkTS 插件持有平台上下文和系统资源Flutter 页面只消费业务类型在创建AudioCapturer前先确认已有或新取得的隐私同意再完成麦克风权限门控在取消订阅、Ability 分离和 Engine 分离时统一释放资源原生侧先做 PCM 归一化、NSDF、阈值过滤和降频推送Dart 侧再做乐器目标弦、音分和会话统计。与此同时文章结论必须停在现有证据能够支持的位置当前源码确实实现了上述链路新 release HAP 也已经生成但当前没有设备最终包没有完成实机回归。下一步若要把“可构建”推进到“可验收”至少需要补充同一个 HAP 哈希对应的安装回执、设备信息、隐私与麦克风顺序截图、已知频率信号测试表、持续采集日志以及参考音播放记录。只有这些证据齐全以后才能讨论准确度、稳定性和性能而不是仅凭源码和界面截图下结论。
返回列表