Skip to content

Commit 67e8127

Browse files
authored
Skip muted interface effects and incorrect-answer beeps (#160)
* Skip muted interface sounds before opening an audio device * Document native muted-output verification and hardware limits * Record the unguarded baseline sound behavior * Gate quiz beeps and pin muted-sound baseline verification * Check the actual alert output before quiz beeps * Keep interface sounds on the live default output without pinning a device * Refresh actual Core Audio muted-output verification
1 parent 97bdea0 commit 67e8127

8 files changed

Lines changed: 216 additions & 1 deletion

File tree

‎packages/ChatUI/Sources/ChatUI/ChatPollItem.swift‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -909,7 +909,7 @@ public final class ChatPollItemView : ChatRowView {
909909
shakeContentView()
910910

911911
if FastSettings.inAppSounds {
912-
NSSound.beep()
912+
playSoundEffectBeep()
913913
}
914914
self.contentNode.showSolution()
915915
}

‎packages/ChatUI/Sources/ChatUI/SoundEffects.swift‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import Cocoa
2+
import CoreAudio
23
#if SHARE
34
import TelegramThemeShare
45
#else
@@ -31,7 +32,39 @@ public enum SoundEffect {
3132
}
3233

3334

35+
private func soundEffectOutputIsAudible(_ outputSelector: AudioObjectPropertySelector = kAudioHardwarePropertyDefaultOutputDevice) -> Bool {
36+
func property<Value>(_ device: AudioObjectID, _ selector: AudioObjectPropertySelector, _ scope: AudioObjectPropertyScope, _ element: AudioObjectPropertyElement, _ initial: Value) -> Value? {
37+
var address = AudioObjectPropertyAddress(mSelector: selector, mScope: scope, mElement: element)
38+
var value = initial
39+
var size = UInt32(MemoryLayout<Value>.size)
40+
let status = withUnsafeMutablePointer(to: &value) { pointer in
41+
AudioObjectGetPropertyData(device, &address, 0, nil, &size, pointer)
42+
}
43+
guard status == noErr, size == MemoryLayout<Value>.size else { return nil }
44+
return value
45+
}
46+
47+
guard let device = property(AudioObjectID(kAudioObjectSystemObject), outputSelector, kAudioObjectPropertyScopeGlobal, kAudioObjectPropertyElementMain, AudioDeviceID(0)), device != kAudioObjectUnknown else { return false }
48+
let scope = kAudioDevicePropertyScopeOutput
49+
let main = kAudioObjectPropertyElementMain
50+
if property(device, kAudioDevicePropertyMute, scope, main, UInt32(0)) == 1 { return false }
51+
if property(device, kAudioDevicePropertyVolumeScalar, scope, main, Float32(1)) == 0 { return false }
52+
53+
var channels: [UInt32] = [0, 0]
54+
var address = AudioObjectPropertyAddress(mSelector: kAudioDevicePropertyPreferredChannelsForStereo, mScope: scope, mElement: main)
55+
var size = UInt32(MemoryLayout<UInt32>.size * channels.count)
56+
let status = channels.withUnsafeMutableBytes { buffer in
57+
AudioObjectGetPropertyData(device, &address, 0, nil, &size, buffer.baseAddress!)
58+
}
59+
if status == noErr, channels.allSatisfy({ $0 != main }), channels.allSatisfy({ channel in
60+
property(device, kAudioDevicePropertyMute, scope, channel, UInt32(0)) == 1 || property(device, kAudioDevicePropertyVolumeScalar, scope, channel, Float32(1)) == 0
61+
}) { return false }
62+
63+
return true
64+
}
65+
3466
public func playSoundEffect(_ sound: SoundEffect) {
67+
guard soundEffectOutputIsAudible() else { return }
3568
let afterSentSound:NSSound? = {
3669

3770
let p = Bundle.main.path(forResource: sound.name, ofType: sound.ext)
@@ -43,5 +76,11 @@ public func playSoundEffect(_ sound: SoundEffect) {
4376

4477
return sound
4578
}()
79+
guard soundEffectOutputIsAudible() else { return }
4680
afterSentSound?.play()
4781
}
82+
83+
public func playSoundEffectBeep() {
84+
guard soundEffectOutputIsAudible(kAudioHardwarePropertyDefaultSystemOutputDevice) else { return }
85+
NSSound.beep()
86+
}

‎tools/tests/muted-sounds/README.md‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
# Muted interface sounds
2+
3+
Run `python3 tools/tests/muted-sounds/run.py`: 20 controlled cases compile the shipping helper and beep wrapper with recording NSSound/Bundle and Core Audio readers. The final summary runs after every case. `--baseline` reads immutable e18830989542a063dd66c5bdc4dbe6a70cbbd175 and intentionally fails; review-before.txt and review-after.txt are the current outputs.
4+
5+
Sent/confetti effects read the default output device on each invocation, avoiding NSSound allocation when muted or zero-volume. Incorrect-answer NSSound.beep uses the separate default system sound-effects device (Play sound effects through) for its mute/volume decision. Tests include muted default output with an audible alert device and the inverse, so these routes cannot be conflated. Named quiz enum sounds are not active poll call sites.
6+
7+
Missing default-device information deliberately drops an optional effect. The helper no longer reads or pins an output UID: NSSound keeps following the current default. Audibility is checked again immediately before play, so a disconnected or newly muted output detected after construction does not start playback. Three additional cases change the route between creation and playback. The supported behavioral fix is avoiding muted audio activation; unmuted AirPods/iPhone handoff is not claimed as fixed or verified.
8+
9+
`--native` uses actual Core Audio reads and recording sound construction for ten sent effects; it never changes volume/routing or emits audio. native-muted-output.txt records the current helper on the muted validation Mac: ten attempts, zero sound allocations and zero play calls. Calls, notifications and user media retain their own paths. No visible screenshot applies. Only macOS 27.0.1 Debug arm64 was available for full-client integration builds.
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
final class NSSound {
2+
static func beep() { played += 1 }
3+
static var created = 0
4+
static var played = 0
5+
static var createdHook: (() -> Void)?
6+
static var currentOutput: () -> String? = { nil }
7+
static var lastPinnedOutput: String?
8+
static var lastOutput: String?
9+
var playbackDeviceIdentifier: String?
10+
var volume: Float = 1
11+
init?(contentsOfFile: String, byReference: Bool) { Self.created += 1; Self.createdHook?() }
12+
@discardableResult func play() -> Bool { Self.played += 1; Self.lastPinnedOutput = playbackDeviceIdentifier; Self.lastOutput = playbackDeviceIdentifier ?? Self.currentOutput(); return true }
13+
}
14+
final class Bundle {
15+
static let main = Bundle()
16+
func path(forResource: String, ofType: String) -> String? { "/test/sent.mp3" }
17+
}
18+
var device: AudioDeviceID? = 7
19+
var systemDevice: AudioDeviceID? = 9
20+
var systemMutes: [UInt32: UInt32] = [:]
21+
var systemVolumes: [UInt32: Float32] = [:]
22+
var uid: String? = "speakers"
23+
var mutes: [UInt32: UInt32] = [:]
24+
var volumes: [UInt32: Float32] = [:]
25+
var channels: [UInt32]? = [1, 2]
26+
func readTestProperty(_ object: AudioObjectID, _ address: UnsafePointer<AudioObjectPropertyAddress>, _ qualifierSize: UInt32, _ qualifier: UnsafeRawPointer?, _ size: UnsafeMutablePointer<UInt32>, _ data: UnsafeMutableRawPointer) -> OSStatus {
27+
switch address.pointee.mSelector {
28+
case kAudioHardwarePropertyDefaultOutputDevice, kAudioHardwarePropertyDefaultSystemOutputDevice:
29+
let selected = address.pointee.mSelector == kAudioHardwarePropertyDefaultSystemOutputDevice ? systemDevice : device
30+
guard let selected else { return -1 }
31+
data.assumingMemoryBound(to: AudioDeviceID.self).pointee = selected
32+
case kAudioDevicePropertyDeviceUID:
33+
guard let uid else { return -1 }
34+
data.assumingMemoryBound(to: Optional<CFString>.self).pointee = uid as CFString
35+
case kAudioDevicePropertyMute:
36+
guard let value = (object == systemDevice ? systemMutes : mutes)[address.pointee.mElement] else { return -1 }
37+
data.assumingMemoryBound(to: UInt32.self).pointee = value
38+
case kAudioDevicePropertyVolumeScalar:
39+
guard let value = (object == systemDevice ? systemVolumes : volumes)[address.pointee.mElement] else { return -1 }
40+
data.assumingMemoryBound(to: Float32.self).pointee = value
41+
case kAudioDevicePropertyPreferredChannelsForStereo:
42+
guard let channels else { return -1 }
43+
for (i, value) in channels.enumerated() { data.assumingMemoryBound(to: UInt32.self)[i] = value }
44+
default: return -1
45+
}
46+
return noErr
47+
}
48+
var failures = 0
49+
var checks = 0
50+
func reset() {
51+
device = 7; systemDevice = 9; systemMutes = [:]; systemVolumes = [:]; uid = "speakers"; mutes = [:]; volumes = [:]; channels = [1, 2]
52+
NSSound.created = 0; NSSound.played = 0; NSSound.lastOutput = nil; NSSound.lastPinnedOutput = nil; NSSound.createdHook = nil; NSSound.currentOutput = { uid }
53+
}
54+
func check(_ name: String, _ condition: Bool) {
55+
checks += 1
56+
if !condition { failures += 1 }
57+
print("\(condition ? "PASS" : "FAIL") \(name)")
58+
}
59+
func suppressed(_ name: String) {
60+
playSoundEffect(.sent)
61+
check(name, NSSound.created == 0 && NSSound.played == 0)
62+
}
63+
reset(); mutes[0] = 1; suppressed("system mute skips sound allocation and playback")
64+
reset(); volumes[0] = 0; suppressed("zero master volume skips audio activation")
65+
reset(); mutes = [1: 1, 2: 1]; suppressed("both output channels muted")
66+
reset(); volumes = [1: 0, 2: 0]; suppressed("zero volume on both channels")
67+
reset(); mutes[1] = 1; volumes[2] = 0; suppressed("mixed mute and zero volume remains silent")
68+
reset(); device = nil; suppressed("missing default output does not fall back to another device")
69+
reset(); device = 0; suppressed("unknown device is not activated")
70+
reset(); uid = nil; playSoundEffect(.sent)
71+
check("an unreadable device UID does not block an audible default output",NSSound.played == 1)
72+
reset(); volumes = [1: 0, 2: 0.5]; playSoundEffect(.sent)
73+
check("one audible channel still plays", NSSound.created == 1 && NSSound.played == 1)
74+
reset(); channels = nil; playSoundEffect(.sent)
75+
check("fixed-volume output without channel controls remains supported", NSSound.played == 1)
76+
check("sound retains default-output routing without pinning a UID", NSSound.lastPinnedOutput == nil)
77+
uid = "wired-headphones"; playSoundEffect(.sent)
78+
check("manual device changes are read on the next sound", NSSound.lastOutput == "wired-headphones")
79+
mutes[0] = 1; let prior = NSSound.created; playSoundEffect(.sent)
80+
check("muting after playback prevents the next allocation", NSSound.created == prior)
81+
reset(); systemMutes[0] = 1; playSoundEffectBeep()
82+
check("incorrect-answer system beep respects mute", NSSound.played == 0)
83+
reset(); playSoundEffectBeep()
84+
check("audible incorrect answer retains the system beep", NSSound.played == 1)
85+
reset(); mutes[0] = 1; playSoundEffectBeep()
86+
check("muted ordinary output does not mute a separate audible alert device", NSSound.played == 1)
87+
reset(); systemMutes[0] = 1; playSoundEffect(.sent); playSoundEffectBeep()
88+
check("muted alert device blocks beep while ordinary output stays audible", NSSound.created == 1 && NSSound.played == 1)
89+
reset(); NSSound.createdHook = { uid = "new-default" }; playSoundEffect(.sent)
90+
check("switching output before play follows the new default", NSSound.lastOutput == "new-default" && NSSound.lastPinnedOutput == nil)
91+
reset(); NSSound.createdHook = { device = nil; uid = nil }; playSoundEffect(.sent)
92+
check("disconnecting the output before playback suppresses the sound", NSSound.played == 0)
93+
reset(); NSSound.createdHook = { mutes[0] = 1 }; playSoundEffect(.sent)
94+
check("muting the new output before playback is rechecked", NSSound.played == 0)
95+
print("\(checks - failures)/\(checks) checks passed")
96+
exit(failures == 0 ? 0 : 1)

‎tools/tests/muted-sounds/run.py‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
from pathlib import Path
2+
import subprocess
3+
import tempfile
4+
root=Path(__file__).resolve().parents[3]
5+
source=(root/'packages/ChatUI/Sources/ChatUI/SoundEffects.swift').read_text()
6+
source=source.replace('#if SHARE\nimport TelegramThemeShare\n#else\nimport TelegramTheme\n#endif','')
7+
if '--baseline' in __import__('sys').argv:
8+
source=subprocess.check_output(['git','show','e18830989542a063dd66c5bdc4dbe6a70cbbd175:packages/ChatUI/Sources/ChatUI/SoundEffects.swift'],cwd=root,text=True).replace('#if SHARE\nimport TelegramThemeShare\n#else\nimport TelegramTheme\n#endif','')
9+
if 'func playSoundEffectBeep()' not in source:
10+
source += '\nfunc playSoundEffectBeep() { NSSound.beep() }\n'
11+
fixture=(Path(__file__).parent/'SoundTests.swift').read_text()
12+
with tempfile.TemporaryDirectory(prefix='muted-sounds-') as scratch:
13+
path=Path(scratch)/'main.swift'
14+
if '--native' in __import__('sys').argv:
15+
fakeSound=fixture[:fixture.index('var device:')]
16+
native='''\nfor _ in 0..<10 { playSoundEffect(.sent) }
17+
let suppressed = !soundEffectOutputIsAudible()
18+
print("Real Core Audio gate suppresses playback: \(suppressed)")
19+
print("NSSound allocations: \(NSSound.created); play calls: \(NSSound.played)")
20+
exit(suppressed && NSSound.created == 0 && NSSound.played == 0 ? 0 : 1)
21+
'''
22+
path.write_text(source+'\n'+fakeSound+native)
23+
else:
24+
path.write_text('import CoreAudio\n'+source.replace('AudioObjectGetPropertyData(', 'readTestProperty(')+'\n'+fixture)
25+
executable=Path(scratch)/'tests'
26+
subprocess.run(['swiftc',str(path),'-o',str(executable)],check=True)
27+
raise SystemExit(subprocess.run([str(executable)]).returncode)
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
Real Core Audio gate suppresses playback: true
2+
NSSound allocations: 0; play calls: 0
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
PASS system mute skips sound allocation and playback
2+
PASS zero master volume skips audio activation
3+
PASS both output channels muted
4+
PASS zero volume on both channels
5+
PASS mixed mute and zero volume remains silent
6+
PASS missing default output does not fall back to another device
7+
PASS unknown device is not activated
8+
PASS an unreadable device UID does not block an audible default output
9+
PASS one audible channel still plays
10+
PASS fixed-volume output without channel controls remains supported
11+
PASS sound retains default-output routing without pinning a UID
12+
PASS manual device changes are read on the next sound
13+
PASS muting after playback prevents the next allocation
14+
PASS incorrect-answer system beep respects mute
15+
PASS audible incorrect answer retains the system beep
16+
PASS muted ordinary output does not mute a separate audible alert device
17+
PASS muted alert device blocks beep while ordinary output stays audible
18+
PASS switching output before play follows the new default
19+
PASS disconnecting the output before playback suppresses the sound
20+
PASS muting the new output before playback is rechecked
21+
20/20 checks passed
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
FAIL system mute skips sound allocation and playback
2+
FAIL zero master volume skips audio activation
3+
FAIL both output channels muted
4+
FAIL zero volume on both channels
5+
FAIL mixed mute and zero volume remains silent
6+
FAIL missing default output does not fall back to another device
7+
FAIL unknown device is not activated
8+
PASS an unreadable device UID does not block an audible default output
9+
PASS one audible channel still plays
10+
PASS fixed-volume output without channel controls remains supported
11+
PASS sound retains default-output routing without pinning a UID
12+
PASS manual device changes are read on the next sound
13+
FAIL muting after playback prevents the next allocation
14+
FAIL incorrect-answer system beep respects mute
15+
PASS audible incorrect answer retains the system beep
16+
PASS muted ordinary output does not mute a separate audible alert device
17+
FAIL muted alert device blocks beep while ordinary output stays audible
18+
PASS switching output before play follows the new default
19+
FAIL disconnecting the output before playback suppresses the sound
20+
FAIL muting the new output before playback is rechecked
21+
8/20 checks passed

0 commit comments

Comments
 (0)