Skip to content

feat(audio): add the audio API, core's part and the nsgotest model - #84

Merged
houko merged 5 commits into
nsgo-dev:mainfrom
houko:feat/audio-api
Nov 15, 2026
Merged

houko merged 5 commits into
nsgo-dev:mainfrom
houko:feat/audio-api

Conversation

@houko

@houko houko commented Nov 14, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Implements the first stages of the audio design merged with #83 (docs/architecture/audio.md) for #36: the audio API, core's part of it, the protocol, an unsupported default on every platform, the nsgotest model, and internal/audiosession. Platform backends come in their own PRs (#89 is Apple). This replaces the earlier PlayFile/PlayBytes API, whose callbacks ran off the engine's goroutine, which the design rejects.

  • audio package (imports core alone, as mobile does, and is portable): LoadSound(cx, Source, func(*Sound, error)), Sound.Play(Voice), Stop, Duration, Close; NewPlayer(cx, Source, PlayerOptions) with Play(func(error)), Preload, Pause, Seek, SetVolume, SetRate, SetLoop, Position, Duration, State and Close; PlayerState; audio.Error with NSH's codes (NotAllowed, Network, Decode, NotSupported, QuotaExceeded, Unavailable, Failed), which errors.Is finds in a failure that carries the platform's words.
  • core's part: sounds and players are numbered from one counter of the process, and every call is a command in the batch of the turn that makes it (ItemAudio), answered by EventAudio. A sound's source is decoded once and shared, by URI or by the SHA-256 of its bytes, within NSH's limits (8 MiB encoded, 5 s decoded with the cut reported by the platform, 64 sounds and 16 MiB decoded per engine). A player's position, duration and state are kept from the platform's events. Callbacks run on the engine's goroutine in a later turn, and never once the sound or player closed or its owner went.
  • Position: the platform counts the seeks it applied and the loops it started over in each progress report, so core drops a report from before the last seek and the position goes back only with a seek or a loop. A seek while paused shows at once and Play starts from there, and Play after the end starts over. A pause the system makes (AudioPaused) makes the player paused. Play right after Pause, before the platform reports the pause, asks the platform to play again rather than taking the player for playing, so a restart (Pause, Seek(0), Play) in one turn plays.
  • Unsupported default: Env.CanPlayAudio (driver.Environment.Audio) is false on every backend, so LoadSound and Play fail at once with Unavailable without asking the platform.
  • Capabilities: core.AccessAudio and the TSX packages' audio capability hold sounds and players, refused with NotAllowed (wrapping core.Error's ERR_ACCESS_DENIED); an http(s) source needs network, as an image's does.
  • nsgotest: where the environment says Audio, App.Sounds records the sounds decoded and played and App.Players each player's commands; AudioDurations gives the durations the platform decodes (the rest fail with Decode), RefusePlayback fails plays with NotAllowed, and players move on only as the test's clock does.
  • internal/audiosession: holds iOS's one AVAudioSession for videos, speech, microphone and the players and sounds to come, at the highest level held (ambient, mixing, playback, recording, dictation), restoring the category from before and deactivating with NotifyOthersOnDeactivation once none is held. Videos, speech and microphone moved onto it, so a video that starts playing with a session of its own no longer takes it from a recording. A playing video that changes its session holds the new level before releasing the old, so the session moves across without being deactivated.
  • Video sessions that change: core.Video gains AudioSessionFunc, bound as its other reactive props are, and TSX's audioSession follows its prop through it, so a playing video's session changes in place instead of being read only at mount; before, a changed TSX audioSession remounted the video, starting a new player at 0. Apple applies a changed VideoConfig.AudioSession without reopening the source; Android opens it again, as before.
  • driver.AudioType names a source's bytes by their first bytes for the backends' decoders.

Choices the design left open: sharing a decoded sound stops at a surface (window or embedded view), because a surface's audio goes as it closes; SetVolume and SetRate take Voice's rule of 0 as 1, as Video's; a player that failed reports PlayerFailed until Play or Preload loads it again, and a preloaded one is PlayerPaused. The design has no open decisions, so ARCHITECTURE.md's open questions are unchanged. No CHANGELOG bullet, as nothing plays on any platform yet.

Validation

  • go tool nsgo check -target all
  • go tool nsgo test: 2027 passed, 14 skipped; the one failure is examples/orbit's TestASwipeTurnsTheRing, which fails about one run in ten without the video session commit too and passes on rerun
  • TestVideoChangesItsAudioSessionInPlace (core) and TestVideoAudioSessionFollowsItsProp (TSX, through nsgotest) change a mounted video's session and check that the same view gets the new config
  • TestPlayerPlaysAfterPause covers Pause, Seek(0), Play in one turn and a second Play before the platform reports the pause
  • GOOS=windows, GOOS=linux, GOOS=android GOARCH=arm64 and GOOS=js GOARCH=wasm go vet of the touched packages; GOOS=ios vet of internal/audiosession, internal/platform/apple and microphone
  • TestLayering and TestPublicAPINamesNoInternalType
  • Every commit builds on its own

Device checks still needed (iPhone, internal/audiosession):

  • Start a microphone.Capture recording, then play a Video with AudioSessionPlayback: the recording keeps receiving samples and the video plays.
  • Start StartSpeech, then play a Video with AudioSessionMixWithOthers: the transcript keeps updating.
  • With Music playing in the background, play a Video with AudioSessionPlayback and stop it: Music pauses, then resumes once the video stops.
  • Play a Video whose AudioSessionFunc returns AudioSessionPlayback (or TSX's audioSession from a signal), then switch it to AudioSessionMixWithOthers while it plays: the video keeps playing without a gap, and background Music may start again alongside it.

@github-actions github-actions Bot added size/XL enhancement New feature or request documentation Improvements or additions to documentation conflict Conflicts with the base branch; resolve it first and removed enhancement New feature or request labels Nov 14, 2026
@houko houko changed the title feat(audio): add portable playback API feat(audio): add the audio API, core's part and the nsgotest model Nov 15, 2026
@github-actions github-actions Bot added macOS iOS core tsx cli 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 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.
Core numbers sounds and players from one counter of the process and sends their commands in the batch of the turn that calls them as ItemAudio requests, keeping a player's position, duration and state from the platform's EventAudio reports. A sound's source is decoded once per surface and shared by digest or URI, within NSH's limits: 8 MiB encoded, 5 seconds decoded, 64 sounds and 16 MiB decoded per engine. Env.CanPlayAudio says the platform plays audio; elsewhere loads and plays fail with AudioUnavailable without asking it. AccessAudio, and the audio capability of TSX packages, hold code to it.
audio.LoadSound, Sound with Play(Voice), audio.NewPlayer with PlayerOptions, PlayerState and audio.Error with NSH's codes are the API of docs/architecture/audio.md, on core's part: the package imports core alone, as mobile does, and stands among the portable packages. The architecture, layers, services and roadmap documents say what exists: the API and core's part, with no platform playing yet.
Where the environment says Audio, the model records the sounds loaded and played (App.Sounds) and each player's commands (App.Players), decodes the durations AudioDurations gives and fails the rest with Decode, refuses plays with NotAllowed under RefusePlayback, and moves a player on only as the test's clock moves, reporting its progress, loops and end. Tests of the audio package assert on what a tap played and a player's progress without sleeping.
iOS has one AVAudioSession per process, which videos, speech and microphone each set without knowing of the others, so a video that started playing with a session of its own took it from a recording. internal/audiosession now holds it for all of them, and for the audio package's players and sounds to come: each holder asks for a level (ambient, mixing, playback, recording or dictation), the highest held sets the category, and once none is held the session goes back to the category from before and is deactivated, telling other apps they may play. It imports nothing of the module, guards its holders with a mutex and does nothing outside iOS.
core.Video read AudioSession only as it mounted, and TSX's audioSession with it, so a changed prop remounted the video and started a new player at 0, and the hold-before-release path of the iOS backend never ran for videos. AudioSessionFunc binds it as the video's other reactive props are, TSX's audioSession goes through it, and the platforms apply the changed config as they already could: Apple without reopening the source, Android by opening it again.
@houko

houko commented Nov 15, 2026

Copy link
Copy Markdown
Collaborator Author

Reviewed. The audio API's core, its nsgotest model and the shared audio session were checked through #89's Apple backend on an iPhone XR (iOS 18.7) and on macOS:

  • A video with AudioSessionPlayback pauses background Music, which resumes when the video stops; AudioSessionMixWithOthers leaves it playing.
  • The PR claimed a playing video could switch its session without a gap, but core.Video had no way to change its session after mounting, so the only route was a remount that restarted playback. 0624c81 adds AudioSessionFunc and makes TSX's audioSession reactive; switching a playing video from Playback to MixWithOthers and back now keeps its position continuous with no new load, on both iOS and macOS. A TSX test fails without the change because the video's view is recreated.

The branch is rebased onto main; every commit builds, and the core, audio, nsgotest, tsx and internal/driver tests pass.

@houko
houko marked this pull request as ready for review November 15, 2026 11:35
@houko
houko merged commit ec83a11 into nsgo-dev:main Nov 15, 2026
6 checks passed
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