三亩地 三亩地SAN MU DI · CODE DIARY
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.7;string_tuner_flutter才是本文分析的 Flutter/OHOS 工程,版本为 1.0.8。Flutter 工程的页面源码位于string_tuner_flutter/lib,HarmonyOS 插件位于string_tuner_flutter/ohos/entry/src/main/ets/plugins

区分这两条工程线十分重要。2026-05-14 的早期截图属于原生 ArkTS 实现,2026-07-05 的截图属于 Flutter 调试运行。两批截图都早于本轮重新生成的 release HAP,不能拿旧截图证明最新包已经回归。本文使用 Flutter 截图,是为了说明当时页面和系统权限弹窗曾经运行,而不是把它包装成这次最终包的验收回执。

Flutter 页面已经包含首页、调音、历史、我的和曲库等入口。曲库页能够说明页面路由和数据渲染已经落地,但静态截图不能证明参考音真的从扬声器播放,也不能证明音色、时延或音量符合预期。

为什么 Flutter 页面不能直接承担系统音频生命周期

Flutter 擅长组织跨平台界面和状态,但 HarmonyOS 的隐私托管、运行时权限、AudioCapturerAudioRendererPreferences都属于平台侧能力。如果页面直接拼接这些能力,会出现三个问题:第一,Dart 页面必须知道平台 API 的细节;第二,麦克风的创建、停止和释放容易散落到多个生命周期回调;第三,连续 PCM 数据如果按一次次方法调用返回,会产生不必要的请求与响应开销。

当前项目把职责切成三层:

  • Flutter 页面决定“现在是否需要实时音频”,并展示TunerFrame
  • NativeTuner统一封装 Dart 到原生的调用和异常回退;
  • StringTunerPlugin持有 Ability 上下文、系统音频对象和事件出口。

这种分层并不意味着所有调音逻辑都放在 ArkTS。插件负责把 PCM 变成频率、清晰度、波形摘要和“频谱”摘要;Flutter 层再根据当前乐器、A4 参考频率和自动/手动模式选择目标弦,并计算音分与会话统计。边界清楚以后,页面不需要持有AudioCapturer,插件也不需要理解具体的卡片布局。

桥接总览:一条命令通道,一条事件通道

项目约定了两个完全一致的通道名:

  • string_tuner/native:方法通道,处理隐私确认、开始/停止采音、屏幕常亮、设置与历史持久化、参考音播放;
  • string_tuner/audio_events:事件通道,持续发送频率、清晰度、波形和摘要数组。

方法通道适合有明确返回值的离散动作,事件通道适合原生侧主动推送的连续状态。页面只在需要实时音频时订阅事件,停止时取消订阅并通知插件释放采音资源。

项目相对路径:string_tuner_flutter/lib/native_tuner.dart

class 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.dart

static Future<MicStartResult> startAudio() async { try { final Map<dynamic, dynamic>? value = await _method .invokeMapMethod<dynamic, 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.dart

static Stream<TunerFrame> 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。页面关心的是acceptedgrantedfrequencyclarity,而不是平台权限返回数组或ArrayBuffer

这种类型收口还能避免一个常见错误:把“已经授权”和“本次刚刚授权”混为一谈。插件返回wasAlreadyGranted,Flutter 页面据此决定是否显示“权限已经授权”的提示;无论哪一种情况,真正能否开始采音仍由granted决定。

项目相对路径:string_tuner_flutter/lib/native_tuner.dart

class MicStartResult { const MicStartResult({ required this.granted, required this.wasAlreadyGranted, }); factory MicStartResult.fromMap(Map<dynamic, dynamic>? value) { return MicStartResult( granted: value?['granted'] == true, wasAlreadyGranted: value?['wasAlreadyGranted'] == true, ); } final bool granted; final bool wasAlreadyGranted; }

ArkTS 插件如何注册并分发方法调用

StringTunerPlugin同时实现FlutterPluginAbilityAwareMethodCallHandlerStreamHandler。它在绑定 Flutter Engine 时创建两个通道,在绑定 Ability 时取得UIAbilityContext。后者是隐私接口、权限请求和窗口控制的必要上下文。

项目相对路径:string_tuner_flutter/ohos/entry/src/main/ets/plugins/StringTunerPlugin.ets

onMethodCall(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.ets

private 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); }

完整源码中还分发setKeepScreenOnloadAppStatesaveSettingssaveSessionsplayReferenceTonestopReferenceTone。本文只截取音频入口相关分支,没有把不存在的方法改写进示例。

订阅和取消同样由插件管理。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.ets

private async startAudio(call: MethodCall, result: MethodResult): Promise<void> { 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 包的实机流程录像或日志。

创建 AudioCapturer:44.1 kHz、16 bit、单声道

通过隐私和权限门控以后,插件才创建AudioCapturer。当前参数是固定的 44.1 kHz、单声道、S16LE、RAW 编码,输入源为麦克风。设置页中即使保存了其他采样率字段,也不能据此宣称采音参数已经动态切换;真正生效的参数应以这里的AudioCapturerOptions为准。

项目相对路径:string_tuner_flutter/ohos/entry/src/main/ets/plugins/StringTunerPlugin.ets

const 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 到 TunerFrame:NSDF 在插件内完成

S16LE 样本先除以 32768 转成约 -1 到 1 的浮点值,并累积到 4096 个样本。达到窗口长度以后,插件取最近一窗执行detectPitch,再保留半窗作为下一次分析的重叠数据。音高范围限制为 40~1500 Hz,能量阈值为 0.0008,NSDF 峰值与清晰度阈值都是 0.65。

项目相对路径:string_tuner_flutter/ohos/entry/src/main/ets/plugins/StringTunerPlugin.ets

private 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 个时间片,根据平均绝对幅值和峰值生成 0~1 的柱形摘要。它可以用于动态视觉反馈,但文章不能把它描述成严格的频谱分析。

为了控制跨端事件频率,摘要每三次分析更新一次,事件每两次分析发送一次。emitFrame的载荷保持简单:

项目相对路径:string_tuner_flutter/ohos/entry/src/main/ets/plugins/StringTunerPlugin.ets

private 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.dart

Future<void> _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('未获得麦克风权限,无法进行实时调音。请在系统设置中开启麦克风权限。'); } } Future<void> _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 test:1 个页面冒烟测试通过,测试只验证首页能显示“弦乐调音器”和“自动”文本;
  • flutter build hap --release:成功生成新的 signed 命名 HAP。

新 HAP 大小为25,692,787 bytes,SHA-256 为:

CF417384958952EF6D0B9B0D6E3090B87944B4C419E7C60D292964312B90C8B2

包内module.json显示版本 1.0.8、debug:falsebuildMode:releasecompileSdkVersion: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 已发布,也不能代表今天的线上状态。本文因此只标记为本地待发布稿,不填写公开地址。

这套桥接方式可以复用,但验证边界不能省略

从工程结构看,这套插件方案有四个值得复用的点:

  1. MethodChannel处理有明确结果的离散命令,用EventChannel推送连续帧;
  2. 由实现AbilityAware的 ArkTS 插件持有平台上下文和系统资源,Flutter 页面只消费业务类型;
  3. 在创建AudioCapturer前先确认已有或新取得的隐私同意,再完成麦克风权限门控,在取消订阅、Ability 分离和 Engine 分离时统一释放资源;
  4. 原生侧先做 PCM 归一化、NSDF、阈值过滤和降频推送,Dart 侧再做乐器目标弦、音分和会话统计。

与此同时,文章结论必须停在现有证据能够支持的位置:当前源码确实实现了上述链路,新 release HAP 也已经生成;但当前没有设备,最终包没有完成实机回归。下一步若要把“可构建”推进到“可验收”,至少需要补充同一个 HAP 哈希对应的安装回执、设备信息、隐私与麦克风顺序截图、已知频率信号测试表、持续采集日志以及参考音播放记录。只有这些证据齐全以后,才能讨论准确度、稳定性和性能,而不是仅凭源码和界面截图下结论。

← 返回列表