Skip to content

feat(audio): add Apple playback backend - #89

Draft
houko wants to merge 7 commits into
nsgo-dev:mainfrom
houko:feat/audio-apple
Draft

houko wants to merge 7 commits into
nsgo-dev:mainfrom
houko:feat/audio-apple

Conversation

@houko

@houko houko commented Nov 14, 2026 •

Copy link
Copy Markdown
Collaborator

What changed

Stage 3 of audio playback for macOS and iOS: the Apple backend behind the audio package of #84, which is merged. The branch is rebased onto main (with #271 and #106) and holds only this PR's seven commits.

  • internal/platform/apple/audio.go is one Audio per engine, on the main thread, modeled on NSH's audio.rs. A surface makes it with its first ItemAudio and closes it as it closes.
  • Sounds. They are read through imageload, held to 8 MiB before decoding, written to a temporary file and decoded into an AVAudioPCMBuffer by AVAudioFile on a goroutine of their own inside an autorelease pool. A sound longer than 5 seconds is reported cut without decoding. Each playback is a voice, up to 32 of them, on one AVAudioEngine per engine. A voice is an AVAudioPlayerNode into an AVAudioUnitVarispeed for the rate, then a mixer of its own for the volume, because an effect node keeps the player's own volume from the main mixer. A varispeed that is already connected refuses another format (-10868), so a sound of another format gets a new voice. The engine starts at a playback, starts again after the system stopped it, and pauses two seconds after the last playback ends.
  • Players. They are layerless AVPlayers, as Video uses, made at the first preload or play. Bytes and data: URLs play from a temporary file that is removed as the player closes.
    • The load is polled every 100 ms for at most a minute.
    • The position comes from a periodic time observer, with the seeks and laps counted for the core.
    • The end comes from AVPlayerItemDidPlayToEndTimeNotification. A looping player seeks to the start there and counts a lap.
    • A stop that no command asked for (an interruption, a lost route) is reported as a system pause as soon as AVPlayerRateDidChangeNotification (macOS 12, iOS 15) says the rate went to 0, and also when the time observer sees it, which covers macOS 11.
    • On iOS, an audio session interruption (AVAudioSessionInterruptionNotification) pauses the players as it begins: they report AudioPaused, stop reporting progress and let their session level go. They stay paused when the interruption ends, even with ShouldResume, because the core has them paused by the system and Play resumes them, as after the app goes to the background. The design now says this.
    • A seek made before the load is applied at the load. Seeks are in nanoseconds, so a load lands where it was asked.
    • A failed player loads again at the next Play or Preload, as the design says, and plays on from the position it last reported, which the core keeps, or from the start after an end.
  • Errors. A path that is not UTF-8 or contains a NUL fails with NotSupported before it reaches NSURL, and so does a local source that cannot be read. http(s) sources fail with Network, and sources that cannot be decoded fail with Decode.
  • Sessions. On iOS, sounds hold the shared session (internal/audiosession) as ambient while they play, and players hold the level their AudioSession asks for while they play. iOS pauses players as the app enters the background.
    • No deactivation on the main thread. On the iPhone XR, every Pause and every background pause blocked the main thread for 593 to 634 ms, and iOS showed its hang banner: the last holder letting go called setActive:NO with NotifyOthersOnDeactivation at once, on the main thread, while the AVPlayer's output was still running down, and AudioPaused waited for it because the release comes before the report. The last release now only arranges the deactivation. It runs one second later on a goroutine of its own, inside an autorelease pool. A hold that comes first cancels it: with the same level it makes no session call at all (a play right after a pause), and with another level it moves the session from the level it still has. A hold that comes while the deactivation runs waits for it under the holders' mutex and then activates the session again, so a deactivation and an activation never overlap, and a new level is still held before the old one goes.
    • A playing video keeps the session. A playing Video whose AudioSessionFunc switched to AudioSessionDefault stalled about 0.8 s later with a ~620 ms hang, also on main: the default session held nothing, so releasing the old level deactivated the session under the playing AVPlayer. A new lowest level, Kept, holds the session active in the category from before the first holder (the app's own) for a video that plays with the default session, so Playback -> Default -> Playback moves the session across without deactivating it.
    • A video's system stop. A video's session followed only its Paused, so an interruption, a lost route or a pause from the controls kept it held at its level until the next command, which is the gap fixed for players. The video now observes AVPlayerRateDidChangeNotification and lets the session go when the rate goes to 0. It holds the session again if the controls play the video.
  • Availability. macOS and iOS report Env.CanPlayAudio. tvOS does not, because the design leaves it out of the first stages.
  • Docs. The design (now including Kept and the deferred deactivation), native-views.md, the roadmap row, ARCHITECTURE.md and the services subject now say that macOS and iOS play audio. CHANGELOG.md is not changed, as it is frozen until 0.1.0.

This replaces the earlier AVAudioPlayer branch, which was written against an API that #84 no longer has. The earlier review findings either no longer apply to this design or are fixed here:

  • The end now comes from an AVPlayerItem notification, not a missing AVAudioPlayer one.
  • Positions go back only with a seek or a loop, through the seek and lap counters.
  • Non-UTF-8 and NUL paths are refused before NSURL.
  • Decoding runs inside autorelease pools.
  • Sessions are held through internal/audiosession.
  • Errors are reported under the design's codes.
  • Opening and decoding run off the main thread and the engine goroutine.
  • New tests drive AVFoundation directly.

Validation

  • go test in internal/platform/apple on macOS: 14 audio tests through real AVFoundation, with the main run loop running. They cover decoding, cuts, quotas, the voice limit across two formats, a player's end and replay, loops, seeks before and after the load, pauses, failures, and a preload and a play that load a failed player again from where it played. M4A (AAC) and FLAC files that afconvert encodes at test time are decoded as sounds and played to their end; these are skipped where afconvert is missing. Undecodable M4A, FLAC and MP3 files fail with Decode, and temporary files are removed. Two of the tests are new:
    • An interruption posted as iOS posts it pauses a playing player at once, its AVPlayer too, with no session held and no more progress. The interruption's end with ShouldResume leaves it paused, and Play resumes it.
    • A player stopped with its time observer removed is still reported paused at once, from the rate change.
    • Both new tests fail without the fix.
  • go test in internal/platform/macos: an app that loads and plays a sound and plays a player to its end through the window's engine.
  • go test in audio, core and internal/audiosession, and go test -run 'Layer|Skills|Doc' . from the root. internal/audiosession's tests (also run with -race -count=3) record the session's moves. They check that the last release moves nothing on its caller's thread, that a hold of the same level before the deactivation moves nothing, that a hold of another level moves across from the level the session still has, and that a hold that comes while the deactivation runs waits for it and then activates the session again.
  • go vet on macOS (arm64 and amd64). An iOS (arm64, cgo) vet and build of internal/audiosession, microphone, internal/platform/apple and internal/platform/ios. Builds of internal/audiosession and microphone for Linux, Windows and Android.

Device checks

On the Mac (output muted, volume left as it was), the current head passed a probe app: Pause reported AudioPaused at once, with the main thread's longest stall at 0.4 ms. MP3, M4A and FLAC each played to their end once. A loop kept playing with no end. A file that appeared late failed with NotSupported, then loaded with Preload and played after a seek to its end. A Video played across Default, Playback and MixWithOthers.

Earlier heads passed these on the iPhone XR: a sound while Music plays (Music keeps playing, and the silent switch silences the sound); AudioSessionPlayback pausing other apps' audio and AudioSessionMixWithOthers mixing with it, both heard in silent mode; a sound during a Video, microphone.Capture and StartSpeech stopping none of them; going to the background pausing players; MP3, M4A and FLAC through a player; and on the Mac, open and close cycles leaving no audio device open.

Needed on the iPhone XR for this head, which has not run there yet:

  • Pause and the background pause: no hang banner, AudioPaused at once, and the session back to its earlier category about a second later.
  • Music started from Control Center pauses a Playback player at once and releases its level. When Music pauses again, the player stays paused, and Play resumes it.
  • A playing Video switched Playback -> Default -> Playback keeps playing with no stall and no hang.
  • A Video interrupted by Music lets its session go.
  • The earlier list again: end once for each format, seek, loop, a broken file, a late file with Preload, and MixWithOthers vs Playback.

@github-actions github-actions Bot added documentation Improvements or additions to documentation size/XL enhancement New feature or request conflict Conflicts with the base branch; resolve it first labels Nov 14, 2026
houko added a commit to houko/nsgo that referenced this pull request Nov 15, 2026
The audio package joins speech recognition and the other services in their bullet, with nsgo-dev#84 and nsgo-dev#89 beside nsgo-dev#71, as the changelog lists the services.
@github-actions github-actions Bot added conflict Conflicts with the base branch; resolve it first and removed conflict Conflicts with the base branch; resolve it first labels Nov 15, 2026
houko added a commit to houko/nsgo that referenced this pull request Nov 15, 2026
The audio package joins speech recognition and the other services in their bullet, with nsgo-dev#84 and nsgo-dev#89 beside nsgo-dev#71, as the changelog lists the services.
@houko
houko force-pushed the feat/audio-apple branch 2 times, most recently from 2994db9 to 82e54e5 Compare November 15, 2026 11:46
@github-actions github-actions Bot removed the conflict Conflicts with the base branch; resolve it first label Nov 15, 2026
Apple's part of audio playback (docs/architecture/audio.md), shared by macOS and iOS and modeled on NSH's audio.rs: an Audio per engine, on the main thread, carrying out ItemAudio and reporting EventAudio.

Sounds are read through imageload, held to their 8 MiB before decoding, written to a temporary file and decoded into an AVAudioPCMBuffer by AVAudioFile on a goroutine of their own inside an autorelease pool; one longer than the cap is reported cut without decoding. Each playback is a voice of up to 32 on the engine's one AVAudioEngine: an AVAudioPlayerNode into an AVAudioUnitVarispeed for the rate and a mixer of its own for the volume, as an effect keeps the player's own volume from the main mixer. A varispeed connected once takes no other format, so a sound of another format gets a voice of its own. The engine starts at a playback and pauses two seconds after the last, so the output is not held open.

Players are layerless AVPlayers made at their first preload or play, with bytes and data: URLs played from a temporary file removed as they close. Their load is polled, their position comes from a periodic time observer with the seeks and laps counted, their end from AVPlayerItemDidPlayToEndTimeNotification, where a looping one seeks to the start and counts a lap, and a stop no command asked for is reported as the system's pause. A seek before the load applies at it. A path that is not UTF-8 or holds a NUL fails with not-supported before it reaches NSURL, as do local sources that cannot be read, while http(s) ones fail with network.

On iOS sounds hold the shared audio session ambient while they play and players at the level their AudioSession asks for, through internal/audiosession.

The tests run AVFoundation for real with the main run loop running in TestMain: decoding, cuts, quotas, the voice limit across formats, a player's end and replay, loops, seeks before and after the load, pauses, failures and the temporary files' removal.
…ple backend

Each surface makes its apple.Audio with its first ItemAudio and closes it as it closes, so an engine's sounds and players go with its window or embedded view. macOS and iOS report Env.CanPlayAudio, tvOS not, as the design leaves it out of the first stages, and iOS pauses the players as the app enters the background, as NSH's do.

A macOS test runs an app in a process of its own that loads and plays a sound and plays a player to its end through the window's engine, the core's callbacks seeing the load, the play and the end.

The design, the roadmap's row, ARCHITECTURE.md and the services subject say macOS and iOS play audio now, iOS not yet on a device.
…ayed

The design says a failed player plays nothing until Play or Preload loads it again, and core's Preload takes a failed player into loading, but AudioPreload only opened it, which a failed player refused, so the app waited in loading for good. A preload now lets the failed player go and loads it again as a play does.

A load after a failure also started the player at the last seek or AudioOpen's position, while the core keeps the position it last reported and takes no lower one without a seek, so OnProgress froze until the player caught up. The player now keeps the position of its last progress or pause report and a load after a failure seeks there, or plays from the start after an end. Its seeks are in nanoseconds rather than 600ths of a second, which could land just before that position.

Tests cover a preload after a failure, playing on after a failure mid-play, M4A (AAC) and FLAC files afconvert encodes at test time as sounds and through a player, and undecodable M4A, FLAC and MP3 files failing with Decode.
…as a system pause at once

On an iPhone, another app's audio interrupting a playing player left it reported playing: the time observer's call as the AVPlayer stopped came while its rate was not yet 0, and none followed, so the player reported no pause, kept holding the session at its level and only paused at the next command.

The engine's audio now observes AVAudioSessionInterruptionNotification, which only iOS posts, and pauses its players as an interruption begins, letting their session go. They stay paused as it ends, even when its options say they may resume, as the core has them paused by the system and the app's Play resumes them, as after the app left the foreground. Each player also observes AVPlayerRateDidChangeNotification, which macOS 12 and iOS 15 post, so a stop no command asked for is reported as the rate goes to 0, the time observer remaining for older macOS.

Tests post an interruption as iOS does and stop a player without its time observer, both reported paused at once with no session held; the design says how an interruption pauses and stays paused.
…d after the last release

On an iPhone XR, every pause of a player blocked the main thread for about 600 ms and iOS showed its hang banner: the last holder letting go deactivated the shared AVAudioSession at once, with NotifyOthersOnDeactivation, on the caller's thread, and the call waits for the system while an AVPlayer's output still runs down after its pause. The player's AudioPaused waited for it too, as the release comes before the report.

The last release now only arranges the deactivation, which runs a second later on a goroutine of its own inside an autorelease pool. A hold before it keeps the session active, with no call at all when its level is the session's, as for a play right after a pause, and moves it from the level it still has otherwise; a hold while it runs waits for it under the holders' mutex, then activates the session again, so a deactivation and an activation never overlap and the new level is still held before the old one goes.

A new level, Kept, below Ambient, holds the session in the category from before the first holder, the app's own, for a video playing with the default session, so that the session is not deactivated under it.

Tests record the session's moves: the last release moves nothing on its caller's thread, a hold of the same level before the deactivation moves nothing, one of another level moves across, and a hold while the deactivation runs waits for it and activates again. The design says how the deactivation is deferred and what Kept is.
…e system stops the video

A playing video whose AudioSessionFunc switched to AudioSessionDefault stalled on an iPhone about 0.8 s later, its position kept: the default session held nothing, so letting the old level go deactivated the session under the playing AVPlayer. A video playing with the default session now holds the session at Kept, the app's own category, taken before the old level goes, so the switch moves the session across and the video plays on; it is deactivated, a second later, once the video pauses, ends or closes.

A video's session also followed only its Paused, so an interruption, a lost route or the controls' pause left it held at its level until the next command, as players' did before. The video now observes AVPlayerRateDidChangeNotification, which macOS 12 and iOS 15 post, and lets the session go as the rate goes to 0, holding it again as the controls play the video.
@github-actions github-actions Bot added the conflict Conflicts with the base branch; resolve it first label Nov 15, 2026

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

cli conflict Conflicts with the base branch; resolve it first core documentation Improvements or additions to documentation enhancement New feature or request iOS macOS size/XL tsx

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant