From f4a466ddf72e140bf6ca7cb727488d9c0b86f170 Mon Sep 17 00:00:00 2001 From: Fangxun Zhao Date: Fri, 10 Jul 2026 18:06:52 +0800 Subject: [PATCH] feat(audio1): add AI noise reduction support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Added AiReduceNoise and AiNoiseSourcePath D-Bus properties for toggling AI noise reduction 2. Implemented writeAiReduceNoise callback and DConfig persistence for AI noise reduction state 3. Added rnnoise virtual source handling: skip port check, prevent auto-switch when active 4. Added GetDefaultInputPortAnyCard() helper for AI noise recovery across cards 5. Registered CheckPort method for Meter and SinkInput in D-Bus export 6. Added aiReduceNoiseEnabled DConfig key for global AI noise reduction setting Log: Add AI noise reduction feature with D-Bus properties, DConfig persistence, and rnnoise virtual source handling feat(audio1): 添加 AI 降噪功能支持 1. 添加 AiReduceNoise 和 AiNoiseSourcePath D-Bus 属性用于开关 AI 降噪 2. 实现 writeAiReduceNoise 回调及 DConfig 持久化 AI 降噪状态 3. 添加 rnnoise 虚拟声源处理:跳过端口检查,开启时阻止自动切换 4. 添加 GetDefaultInputPortAnyCard() 辅助方法用于跨声卡恢复 AI 降噪 5. 为 Meter 和 SinkInput 注册 CheckPort 方法到 D-Bus 导出 6. 添加 aiReduceNoiseEnabled DConfig 键用于全局 AI 降噪设置 Log: 新增 AI 降噪功能,包含 D-Bus 属性、DConfig 持久化及 rnnoise 虚拟声源处理 PMS: STORY-41229 --- audio1/audio.go | 284 +++++++++++++++++- audio1/audio_dbusutil.go | 28 +- audio1/audio_events.go | 28 ++ audio1/config_keeper.go | 11 + audio1/module.go | 7 +- audio1/source.go | 8 +- .../org.deepin.dde.daemon.audio.json | 20 ++ 7 files changed, 382 insertions(+), 4 deletions(-) diff --git a/audio1/audio.go b/audio1/audio.go index d60ea98e3..44ed29d6e 100644 --- a/audio1/audio.go +++ b/audio1/audio.go @@ -9,8 +9,10 @@ import ( "fmt" "math" "os" + "os/exec" "regexp" "sort" + "strconv" "strings" "sync" "time" @@ -63,6 +65,9 @@ const ( dsgKeyBluezModeDefault = "bluezModeDefault" dsgKeyMonoEnabled = "monoEnabled" dsgKeyReduceNoiseEnabled = "reduceNoiseEnabled" // 降噪配置,保存用户数据 + dsgKeyAiReduceNoiseEnabled = "aiReduceNoiseEnabled" + dsgKeyAiNoiseSourceName = "aiNoisePhysicalSourceName" // 开启AI降噪时持久化的物理麦克风名 + rnnoiseSourceName = "effect_output.rnnoise" dsgKeyFirstRun = "firstRun" dsgKeyInputVolume = "inputVolume" @@ -95,7 +100,7 @@ const ( AudioStateChanging = false ) -//go:generate dbusutil-gen -type Audio,Sink,SinkInput,Source,Meter -import github.com/godbus/dbus audio.go sink.go sinkinput.go source.go meter.go +//go:generate dbusutil-gen -type Audio,Sink,SinkInput,Source,Meter -import github.com/godbus/dbus/v5 audio.go sink.go sinkinput.go source.go meter.go //go:generate dbusutil-gen em -type Audio,Sink,SinkInput,Source,Meter func objectPathSliceEqual(v1, v2 []dbus.ObjectPath) bool { @@ -151,6 +156,12 @@ type Audio struct { ReduceNoise bool `prop:"access:rw"` + AiReduceNoise bool `prop:"access:rw"` + + AiNoiseSourcePath dbus.ObjectPath + + aiNoiseSourceName string + defaultPaCfg defaultPaConfig // 最大音量 @@ -240,6 +251,10 @@ func newAudio(service *dbusutil.Service) *Audio { a.PausePlayer = false a.ReduceNoise = false a.emitPropChangedReduceNoise(a.ReduceNoise) + a.AiReduceNoise = false + a.emitPropChangedAiReduceNoise(a.AiReduceNoise) + a.AiNoiseSourcePath = "/" + a.emitPropChangedAiNoiseSourcePath(a.AiNoiseSourcePath) a.CurrentAudioServer = a.getCurrentAudioServer() a.headphoneUnplugAutoPause, err = a.audioDConfig.GetValueBool(dsgKeyHeadphoneUnplugAutoPause) if err != nil { @@ -804,6 +819,10 @@ func (a *Audio) init() error { // 更新本地数据 a.refresh() + + // After refresh, run AI noise-reduction init with both DConfig and + // source list available. + a.initAiReduceNoise() logger.Debug("init cards") a.PropsMu.Lock() a.setPropCards(a.cards.string()) @@ -941,6 +960,29 @@ func (a *Audio) findSource(cardId uint32, activePortName string) *Source { return nil } +// getSourcePathByName returns the D-Bus object path of the source whose Name +// matches, or "/" if not found. Used to expose the physical mic's Source path +// to control center while AI noise reduction is enabled. +func (a *Audio) getSourcePathByName(name string) dbus.ObjectPath { + if name == "" { + return "/" + } + a.mu.Lock() + defer a.mu.Unlock() + for _, source := range a.sources { + if source.Name == name { + return source.getPath() + } + } + return "/" +} + +// updateAiNoiseSourcePath refreshes the AiNoiseSourcePath D-Bus property from +// aiNoiseSourceName. Call under PropsMu. +func (a *Audio) updateAiNoiseSourcePath() { + a.setPropAiNoiseSourcePath(a.getSourcePathByName(a.aiNoiseSourceName)) +} + func (a *Audio) SetPortEnabled(cardId uint32, portName string, enabled bool) *dbus.Error { logger.Infof("dbus call SetPortEnabled with cardId %d, portName %s and enabled %t", cardId, portName, enabled) @@ -1868,6 +1910,19 @@ func (a *Audio) initDsgProp() error { } getReduceNoiseEnabled() + getAiReduceNoiseEnabled := func() { + aiReduceNoiseEnabled, err := a.audioDConfig.GetValueBool(dsgKeyAiReduceNoiseEnabled) + if err != nil { + logger.Warningf("aiReduceNoise DConfig: GetValueBool failed: %v", err) + return + } + logger.Infof("aiReduceNoise: DConfig=%v, calling setAiReduceNoise", aiReduceNoiseEnabled) + a.setAiReduceNoise(aiReduceNoiseEnabled) + } + // NOTE: do NOT call getAiReduceNoiseEnabled() during initDsgProp — it runs + // before refresh() and would get an empty default-source name. The actual + // init is done in initAiReduceNoise() after refresh(). + a.audioDConfig.ConnectValueChanged(func(key string) { switch key { case dsgKeyAutoSwitchPort: @@ -1884,6 +1939,8 @@ func (a *Audio) initDsgProp() error { getMonoEnabled() case dsgKeyVolumeIncrease: a.handleVolumeIncrease() + case dsgKeyAiReduceNoiseEnabled: + getAiReduceNoiseEnabled() } }) @@ -2009,3 +2066,228 @@ func (a *Audio) isModuleExist(name string) bool { } return false } + +func (a *Audio) paDefaultSourceIs(target string) bool { + out, err := exec.Command("pactl", "get-default-source").Output() + if err != nil { + return false + } + return strings.TrimSpace(string(out)) == target +} + +func (a *Audio) recoverPhysicalSourceName() string { + runtimeDir := os.Getenv("XDG_RUNTIME_DIR") + if runtimeDir == "" { + runtimeDir = "/run/user/" + strconv.Itoa(os.Getuid()) + } + + // 1) rnnoise-toggle's saved original source (tmpfs, may be gone after reboot). + stateFile := runtimeDir + "/rnnoise-toggle-original-source" + if data, err := os.ReadFile(stateFile); err == nil { + name := strings.TrimSpace(string(data)) + if name != "" && name != rnnoiseSourceName { + return name + } + } + + // 2) ConfigKeeper: find the source whose ActivePort matches the user's + // saved DefaultInputPort. This is the most reliable source across reboots + // because it records the port the user manually selected. + defaultPort := GetConfigKeeper().GetDefaultInputPortAnyCard() + if defaultPort != "" { + a.mu.Lock() + for _, s := range a.sources { + if s.ActivePort.Name == defaultPort && s.Name != rnnoiseSourceName { + name := s.Name + a.mu.Unlock() + return name + } + } + a.mu.Unlock() + } + + // 3) DConfig — survives reboot but can be stale/wrong on multi-mic systems. + if name, err := a.audioDConfig.GetValueString(dsgKeyAiNoiseSourceName); err == nil { + name = strings.TrimSpace(name) + if name != "" && name != rnnoiseSourceName { + return name + } + } + + // 4) Fallback: scan pactl source list for a non-rnnoise, non-monitor source. + out, err := exec.Command("pactl", "list", "sources", "short").Output() + if err != nil { + return "" + } + for _, line := range strings.Split(string(out), "\n") { + fields := strings.Fields(line) + if len(fields) >= 2 { + name := fields[1] + if name != rnnoiseSourceName && !strings.Contains(name, ".monitor") { + return name + } + } + } + return "" +} + +func (a *Audio) setAiReduceNoise(enable bool) error { + a.PropsMu.Lock() + sameAsCurrent := a.AiReduceNoise == enable + if sameAsCurrent { + // Already in the requested state. + a.PropsMu.Unlock() + return nil + } + + // Compute aiNoiseSourceName under lock before rnnoise-toggle changes + // the PA default source (which would trigger updateDefaultSource on + // the PA goroutine and race on aiNoiseSourceName). + var micName string + var micNameRecovered bool // true if micName came from recoverPhysicalSourceName() + if enable { + if defaultPort := GetConfigKeeper().GetDefaultInputPortAnyCard(); defaultPort != "" { + a.mu.Lock() + for _, s := range a.sources { + if s.ActivePort.Name == defaultPort && s.Name != rnnoiseSourceName { + micName = s.Name + break + } + } + a.mu.Unlock() + } + if micName == "" { + micName = a.getDefaultSourceName() + if micName == rnnoiseSourceName || micName == "" { + micName = a.recoverPhysicalSourceName() + micNameRecovered = true + } + } + a.aiNoiseSourceName = micName + } else { + micName = a.aiNoiseSourceName + } + a.PropsMu.Unlock() + + // Phase 2: Execute PA operations (outside PropsMu to avoid blocking + // PA event handlers like updateDefaultSource). + if enable { + // Enable rnnoise: rnnoise-toggle saves the current default source + // (the physical mic) and sets effect_output.rnnoise as default. + cmd := exec.Command("rnnoise-toggle", "-q", "on") + err := cmd.Run() + if err != nil { + // rnnoise-toggle -q on exits 1 when default source is already + // effect_output.rnnoise (save_original_source rejects its own). + if a.paDefaultSourceIs(rnnoiseSourceName) { + logger.Warningf("rnnoise-toggle -q on reported failure "+ + "but default source is already %s; continuing", rnnoiseSourceName) + } else { + logger.Warning("failed to enable ai reduce noise:", err) + return err + } + } + } else { + args := []string{"-q", "off"} + if micName != "" { + args = append(args, micName) + } + cmd := exec.Command("rnnoise-toggle", args...) + err := cmd.Run() + if err != nil { + if !a.paDefaultSourceIs(rnnoiseSourceName) { + logger.Warningf("rnnoise-toggle -q off reported failure "+ + "but default source is no longer %s; continuing", rnnoiseSourceName) + } else { + logger.Warning("failed to disable ai reduce noise:", err) + return err + } + } + } + + // Phase 3: Update property and persist DConfig (only on success). + a.PropsMu.Lock() + a.setPropAiReduceNoise(enable) + if enable { + // AiNoiseSourceName was computed in Phase 1; refresh the path so + // control center can bind the volume slider to the physical mic. + a.updateAiNoiseSourcePath() + } else { + // Clear the path — DefaultSource is the physical mic again. + if a.AiNoiseSourcePath != "/" { + a.AiNoiseSourcePath = "/" + a.emitPropChangedAiNoiseSourcePath(a.AiNoiseSourcePath) + } + } + a.PropsMu.Unlock() + if err := a.audioDConfig.SetValue(dsgKeyAiReduceNoiseEnabled, enable); err != nil { + logger.Warning("failed to persist aiReduceNoiseEnabled to dconfig:", err) + } + if enable && !micNameRecovered { + if err := a.audioDConfig.SetValue(dsgKeyAiNoiseSourceName, micName); err != nil { + logger.Warning("failed to persist aiNoisePhysicalSourceName to dconfig:", err) + } + } else if !enable { + if err := a.audioDConfig.SetValue(dsgKeyAiNoiseSourceName, ""); err != nil { + logger.Warning("failed to clear aiNoisePhysicalSourceName in dconfig:", err) + } + } + return nil +} + +func (a *Audio) initAiReduceNoise() { + logger.Debug("initAiReduceNoise") + + // 1. Check if rnnoise source already exists in the source list. + a.mu.Lock() + rnnoiseFound := false + for _, src := range a.sources { + if src.Name == rnnoiseSourceName { + rnnoiseFound = true + break + } + } + a.mu.Unlock() + + // 2. Check DConfig value. + aiReduceNoiseEnabled, err := a.audioDConfig.GetValueBool(dsgKeyAiReduceNoiseEnabled) + if err != nil { + logger.Warningf("initAiReduceNoise: DConfig GetValueBool failed: %v", err) + } + + shouldEnable := aiReduceNoiseEnabled + if err != nil && rnnoiseFound { + shouldEnable = true + } + if !shouldEnable { + logger.Infof("initAiReduceNoise: DConfig=%v (err=%v), rnnoiseFound=%v, nothing to do", + aiReduceNoiseEnabled, err != nil, rnnoiseFound) + return + } + + logger.Infof("initAiReduceNoise: DConfig=%v, rnnoiseFound=%v, calling setAiReduceNoise(true)", + aiReduceNoiseEnabled, rnnoiseFound) + if err := a.setAiReduceNoise(true); err != nil { + logger.Warning("initAiReduceNoise: setAiReduceNoise(true) failed:", err) + return + } + + a.PropsMu.RLock() + micName := a.aiNoiseSourceName + a.PropsMu.RUnlock() + if micName != "" { + a.mu.Lock() + var micSource *Source + for _, s := range a.sources { + if s.Name == micName { + micSource = s + break + } + } + a.mu.Unlock() + if micSource != nil { + logger.Infof("initAiReduceNoise: resumeSourceConfig for %s", micName) + a.resumeSourceConfig(micSource) + } + } +} diff --git a/audio1/audio_dbusutil.go b/audio1/audio_dbusutil.go index b6ac90bdd..c1d51396d 100644 --- a/audio1/audio_dbusutil.go +++ b/audio1/audio_dbusutil.go @@ -1,4 +1,4 @@ -// Code generated by "dbusutil-gen -type Audio,Sink,SinkInput,Source,Meter -import github.com/godbus/dbus audio.go sink.go sinkinput.go source.go meter.go"; DO NOT EDIT. +// Code generated by "dbusutil-gen -type Audio,Sink,SinkInput,Source,Meter -import github.com/godbus/dbus/v5 audio.go sink.go sinkinput.go source.go meter.go"; DO NOT EDIT. package audio @@ -344,6 +344,32 @@ func (v *Audio) emitPropChangedReduceNoise(value bool) error { return v.service.EmitPropertyChanged(v, "ReduceNoise", value) } +func (v *Audio) setPropAiReduceNoise(value bool) (changed bool) { + if v.AiReduceNoise != value { + v.AiReduceNoise = value + v.emitPropChangedAiReduceNoise(value) + return true + } + return false +} + +func (v *Audio) emitPropChangedAiReduceNoise(value bool) error { + return v.service.EmitPropertyChanged(v, "AiReduceNoise", value) +} + +func (v *Audio) setPropAiNoiseSourcePath(value dbus.ObjectPath) (changed bool) { + if v.AiNoiseSourcePath != value { + v.AiNoiseSourcePath = value + v.emitPropChangedAiNoiseSourcePath(value) + return true + } + return false +} + +func (v *Audio) emitPropChangedAiNoiseSourcePath(value dbus.ObjectPath) error { + return v.service.EmitPropertyChanged(v, "AiNoiseSourcePath", value) +} + func (v *Audio) setPropMaxUIVolume(value float64) (changed bool) { if v.MaxUIVolume != value { v.MaxUIVolume = value diff --git a/audio1/audio_events.go b/audio1/audio_events.go index e5ee4fe37..edf96a0c2 100644 --- a/audio1/audio_events.go +++ b/audio1/audio_events.go @@ -83,6 +83,9 @@ func (a *Audio) handleStateChanged() { select { case state := <-a.stateChan: switch state { + case pulse.ContextStateReady: + logger.Debug("re-checking AI noise reduction") + a.initAiReduceNoise() case pulse.ContextStateFailed: logger.Warning("pulseaudio context state failed") a.destroyCtxRelated() @@ -243,6 +246,16 @@ func (a *Audio) checkAutoSwitchInputPort() (auto bool, cardId uint32, portName s } func (a *Audio) autoSwitchInputPort() bool { + // When AI noise reduction is on, the default source must remain + // effect_output.rnnoise — auto-switching to the physical mic would + // undo rnnoise-toggle and make the feature ineffective. + a.PropsMu.RLock() + aiNoise := a.AiReduceNoise + a.PropsMu.RUnlock() + if aiNoise { + logger.Debug("skip autoSwitchInputPort: AiReduceNoise is on") + return true + } auto, cardId, portName := a.checkAutoSwitchInputPort() if auto { if cardId == 0 || portName == "" { @@ -535,6 +548,10 @@ func (a *Audio) handleSourceAdded(idx uint32) { if source.Name == reduceNoiseSourceName && a.ReduceNoise { logger.Info("set reduceNoise as default source", a.getMasterNameFromVirtualDevice(reduceNoiseSourceName)) a.ctx.SetDefaultSource(reduceNoiseSourceName) + } else if source.Name == rnnoiseSourceName { + // AI noise reduction virtual source; skip port auto-switch + // which would reset the default source to the physical mic. + return } else if !isPhysicalDevice(source.Name) { // 其他的虚拟通道不做自动切换处理 return @@ -747,6 +764,7 @@ func isPhysicalDevice(deviceName string) bool { "Echo-Cancel", "remap-sink-mono", "null-sink", + "effect_output.rnnoise", } { if strings.Contains(deviceName, virtualDeviceKey) { return false @@ -781,6 +799,16 @@ func (a *Audio) writeReduceNoise(write *dbusutil.PropertyWrite) *dbus.Error { return nil } +// 外部修改AiReduceNoise时触发回调 +func (a *Audio) writeAiReduceNoise(write *dbusutil.PropertyWrite) *dbus.Error { + reduce, ok := write.Value.(bool) + if !ok { + logger.Warning("type is not bool") + return dbusutil.ToError(errors.New("type is not bool")) + } + return dbusutil.ToError(a.setAiReduceNoise(reduce)) +} + func (a *Audio) writeKeyPausePlayer(write *dbusutil.PropertyWrite) *dbus.Error { return dbusutil.ToError(func() error { a.PausePlayer = write.Value.(bool) diff --git a/audio1/config_keeper.go b/audio1/config_keeper.go index 408812d39..3f4a3899d 100644 --- a/audio1/config_keeper.go +++ b/audio1/config_keeper.go @@ -332,6 +332,17 @@ func (ck *ConfigKeeper) GetDefaultPort(cardName string, direction int) string { return "" } +func (ck *ConfigKeeper) GetDefaultInputPortAnyCard() string { + ck.mu.Lock() + defer ck.mu.Unlock() + for _, cardCfg := range ck.Cards { + if cardCfg.DefaultInputPort != "" { + return cardCfg.DefaultInputPort + } + } + return "" +} + func (card *CardConfig) UpdatePortConfig(portConfig *PortConfig) { card.Ports[portConfig.Name] = portConfig } diff --git a/audio1/module.go b/audio1/module.go index 0b5974af7..c2f3552ef 100644 --- a/audio1/module.go +++ b/audio1/module.go @@ -1,4 +1,4 @@ -// SPDX-FileCopyrightText: 2018 - 2022 UnionTech Software Technology Co., Ltd. +// SPDX-FileCopyrightText: 2018 - 2026 UnionTech Software Technology Co., Ltd. // // SPDX-License-Identifier: GPL-3.0-or-later @@ -68,6 +68,11 @@ func (m *Module) start() error { logger.Warning("failed to bind callback for ReduceNoise:", err) } + err = so.SetWriteCallback(m.audio, "AiReduceNoise", m.audio.writeAiReduceNoise) + if err != nil { + logger.Warning("failed to bind callback for AiReduceNoise:", err) + } + err = so.SetWriteCallback(m.audio, "PausePlayer", m.audio.writeKeyPausePlayer) if err != nil { diff --git a/audio1/source.go b/audio1/source.go index 4e410fc36..af7f81806 100644 --- a/audio1/source.go +++ b/audio1/source.go @@ -49,8 +49,14 @@ func newSource(sourceInfo *pulse.Source, audio *Audio) *Source { return s } -// 检测端口是否被禁用 +// 检测端口是否被禁用。 +// 对于虚拟声源(如 AI 降噪 rnnoise 虚拟设备),无声卡/活跃端口,跳过检查。 func (s *Source) CheckPort() *dbus.Error { + if s.Card == 0 && s.ActivePort.Name == "" { + // 虚拟声源没有声卡/活跃端口,跳过端口启用状态检查 + return nil + } + enabled, err := s.audio.IsPortEnabled(s.Card, s.ActivePort.Name) if err != nil { return err diff --git a/misc/dsg-configs/org.deepin.dde.daemon.audio.json b/misc/dsg-configs/org.deepin.dde.daemon.audio.json index 59c40f7c1..3c1f572d5 100644 --- a/misc/dsg-configs/org.deepin.dde.daemon.audio.json +++ b/misc/dsg-configs/org.deepin.dde.daemon.audio.json @@ -179,6 +179,26 @@ "description[zh_CN]": "是否禁用ALSA混音器自动静音模式", "permissions": "readwrite", "visibility": "private" + }, + "aiReduceNoiseEnabled": { + "value": false, + "serial": 0, + "flags": ["global"], + "name": "AiReduceNoiseEnabled", + "name[zh_CN]": "AI降噪", + "description": "enable ai reduce noise", + "permissions": "readwrite", + "visibility": "private" + }, + "aiNoisePhysicalSourceName": { + "value": "", + "serial": 0, + "flags": ["global"], + "name": "AiNoisePhysicalSourceName", + "name[zh_CN]": "AI降噪物理麦克风名", + "description": "physical microphone source name saved when AI noise reduction is enabled, used to restore the correct mic after daemon restart", + "permissions": "readwrite", + "visibility": "private" } } } \ No newline at end of file