{"_id":"@afkcodes/timbre-audio-session","name":"@afkcodes/timbre-audio-session","dist-tags":{"latest":"0.1.0"},"versions":{"0.1.0":{"name":"@afkcodes/timbre-audio-session","version":"0.1.0","description":"Single arbiter of the OS audio session: focus, interruptions, becoming-noisy and route changes, powered by Nitro Modules","main":"./lib/commonjs/index.js","module":"./lib/module/index.js","types":"./lib/typescript/src/index.d.ts","react-native":"src/index","source":"src/index","scripts":{"typecheck":"tsc --noEmit","clean":"git clean -dfX","release":"semantic-release","test":"vitest run","test:watch":"vitest","build":"npm run typecheck && bob build","codegen":"nitrogen --logLevel=\"debug\" && npm run build","pod":"npm --prefix ../../apps/example run pod"},"keywords":["react-native","nitro-modules","audio-session","audio-focus","avaudiosession"],"repository":{"type":"git","url":"git+https://github.com/afkcodes/timbre.git"},"author":{"name":"Ashish Kumar"},"license":"MIT","bugs":{"url":"https://github.com/afkcodes/timbre/issues"},"homepage":"https://github.com/afkcodes/timbre#readme","publishConfig":{"access":"public","registry":"https://registry.npmjs.org/"},"devDependencies":{"@jamesacarr/eslint-formatter-github-actions":"^0.2.0","@semantic-release/changelog":"^7.0.0","@semantic-release/git":"^11.0.1","@types/react":"19.2.18","conventional-changelog-conventionalcommits":"^10.2.1","nitrogen":"^0.37.0","react":"19.2.8","react-native":"0.87.1","react-native-builder-bob":"^0.43.0","react-native-nitro-modules":"^0.37.0","semantic-release":"^25.0.7","typescript":"^7.0.2","vitest":"^4.1.10"},"peerDependencies":{"react":"*","react-native":">=0.82.0","react-native-nitro-modules":"*"},"eslintConfig":{"root":true,"extends":["@react-native","prettier"],"plugins":["prettier"],"rules":{"prettier/prettier":["warn",{"quoteProps":"consistent","singleQuote":true,"tabWidth":2,"trailingComma":"es5","useTabs":false}]}},"eslintIgnore":["node_modules/","lib/","android/build/","plugin/build/","nitrogen/"],"prettier":{"quoteProps":"consistent","singleQuote":true,"tabWidth":2,"trailingComma":"es5","useTabs":false,"semi":false},"react-native-builder-bob":{"source":"src","output":"lib","targets":["commonjs","module",["typescript",{"project":"tsconfig.build.json"}]]},"gitHead":"5563c3876bb03f73dd5f1e41b1e945a903a7708a","_id":"@afkcodes/timbre-audio-session@0.1.0","_nodeVersion":"24.12.0","_npmVersion":"11.6.2","dist":{"integrity":"sha512-ItQIf9nQBgSdGs5jE/eFRtSt4PWXXrgprgL6X09/nkxO7nRBxPY+g+/7CjpaxgP46JHn84wNkUXu2DdELnHNQQ==","shasum":"e27f78cdc300a03be5fc43edce2fc3a1f44d927d","tarball":"https://registry.npmjs.org/@afkcodes/timbre-audio-session/-/timbre-audio-session-0.1.0.tgz","fileCount":145,"unpackedSize":416703,"signatures":[{"keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U","sig":"MEYCIQDNGfSdeofD91+Qrci93jeSz8zz02NnuBd/D/bpFoEq1wIhAOCHa4dS5zgo7R9pD35OKjIYKtVloHZ9Rqy8g6IUAcYE"}]},"_npmUser":{"name":"afkashish","email":"ashish.kmr@outlook.com"},"directories":{},"maintainers":[{"name":"afkashish","email":"ashish.kmr@outlook.com"}],"_npmOperationalInternal":{"host":"s3://npm-registry-packages-npm-production","tmp":"tmp/timbre-audio-session_0.1.0_1787850291766_0.3092819066800303"},"_hasShrinkwrap":false}},"time":{"created":"2026-08-27T17:04:51.554Z","0.1.0":"2026-08-27T17:04:51.994Z","modified":"2026-08-27T17:04:52.180Z"},"maintainers":[{"name":"afkashish","email":"ashish.kmr@outlook.com"}],"description":"Single arbiter of the OS audio session: focus, interruptions, becoming-noisy and route changes, powered by Nitro Modules","homepage":"https://github.com/afkcodes/timbre#readme","keywords":["react-native","nitro-modules","audio-session","audio-focus","avaudiosession"],"repository":{"type":"git","url":"git+https://github.com/afkcodes/timbre.git"},"author":{"name":"Ashish Kumar"},"bugs":{"url":"https://github.com/afkcodes/timbre/issues"},"license":"MIT","readme":"# `@afkcodes/timbre-audio-session`\n\nThe single arbiter of the OS audio session for React Native: category and focus\nconfiguration, interruptions, headphone unplugs and route changes, unified across\n`AVAudioSession` (iOS) and `AudioFocusRequest` (Android). Nitro Module, Kotlin +\nSwift, no C++ of its own, and zero dependency on `@afkcodes/timbre-player` in either\ndirection — integration is an explicit helper, never ambient coupling\n([ARCHITECTURE §26](../../ARCHITECTURE.md#26-avaudiosession-has-exactly-one-owner-and-it-is-not-the-engine)).\n\n## Installation\n\n```sh\nnpm install @afkcodes/timbre-audio-session react-native-nitro-modules\n```\n\nThe Android manifest needs nothing.\n\n## Usage\n\n```ts\nimport { AudioSession, AudioSessionPresets } from '@afkcodes/timbre-audio-session'\n\nawait AudioSession.configure(AudioSessionPresets.music) // or .speech\nconst granted = await AudioSession.activate()\nif (granted) player.play()\n\nAudioSession.addListener('interruption', (event) => {\n  if (event.begin) {\n    // event.type: 'duck' | 'pause', event.permanent: boolean\n  } else {\n    // event.shouldResume: boolean\n  }\n})\nAudioSession.addListener('becomingNoisy', () => player.pause())\nAudioSession.addListener('routeChange', (e) => console.log(e.reason))\n\nawait AudioSession.deactivate()\n```\n\nOr hand the whole policy to `wireAudioSession`:\n\n```ts\nimport { AudioSessionPresets, wireAudioSession } from '@afkcodes/timbre-audio-session'\n\nconst unwire = wireAudioSession(player, {\n  preset: AudioSessionPresets.music,\n  duckVolume: 0.3,\n  resumeAfterInterruption: true,\n})\n```\n\nIt ducks on a `duck` interruption and restores the exact previous volume\nafterwards; pauses on a `pause` interruption and resumes only when the OS says\n`shouldResume` and the loss was not permanent; pauses on `becomingNoisy` and\nnever auto-resumes after one. Everything is event-driven — no timers, no polling.\nActivating the session before `play()` stays the app's job.\n\n## API\n\n| | what it does | notes |\n|---|---|---|\n| `AudioSession.configure(config): Promise<void>` | `AVAudioSession` category / Android `AudioAttributes` | On Android it is stored for the next `activate()` |\n| `AudioSession.activate(): Promise<boolean>` | Requests focus | `false` is a refusal, not an error |\n| `AudioSession.deactivate(): Promise<void>` | Releases it | Rejects with `isBusy` on iOS if still playing |\n| `AudioSession.addListener(event, fn): Unsubscribe` | `'interruption' \\| 'becomingNoisy' \\| 'routeChange'` | `interruption` is `{ begin, type: 'duck' \\| 'pause', permanent, shouldResume }` |\n| `AudioSessionPresets.music` / `.speech` | Ready-made configs | `speech` pauses instead of ducking, because ducked speech is unintelligible |\n| `wireAudioSession(player, options?): Unsubscribe` | Duck / pause / resume / stop-on-unplug, in one call | `{ preset?, duckVolume? (0.3), resumeAfterInterruption? (true), session?, onError? }` |\n\n`player` is structural — anything with `{ play, pause, setVolume, getVolume }`,\noptionally `isPlaying` and `onStateChange`. Implementing `isPlaying` is what keeps\na *user's* pause sacred across an interruption.\n\n| Preset | `music` | `speech` |\n|---|---|---|\n| iOS mode | `default` | `spokenAudio` |\n| Android content type | `CONTENT_TYPE_MUSIC` | `CONTENT_TYPE_SPEECH` |\n| `willPauseWhenDucked` | `false` (the system ducks us) | `true` (we are told, and pause) |\n\nThere is no typed error taxonomy here: `configure`, `activate` and `deactivate`\nreject with the platform's own error — an `NSError` in `NSOSStatusErrorDomain` on\niOS, a `Throwable` on Android. Refusals are *not* errors; `activate()` answers\n`false`.\n\n## Platform parity\n\nAudio focus (Android) and `AVAudioSession` (iOS) are different models. Every row\nbelow is either identical on both platforms or a cited ceiling. There is no\nmember that exists on one platform and quietly does nothing on the other.\n\n| Member | Android | iOS | Verdict |\n|---|---|---|---|\n| `configure()` | stores the `AudioAttributes` + focus gain for the next `activate()` | `setCategory(_:mode:policy:options:)`, applied immediately | **timing differs** — Android has no session object, since both are constructor arguments of an `AudioFocusRequest` that exists only as an argument to `requestAudioFocus`. Configure *before* activating and the platforms agree |\n| `activate()` | `requestAudioFocus`; `true` only for `AUDIOFOCUS_REQUEST_GRANTED` | `setActive(true)`; `false` for exactly `cannotStartPlaying`, `cannotInterruptOthers`, `insufficientPriority` and `siriIsRecording` | **same contract** — everything else rejects, because those are statements about the call, not a contested resource |\n| `deactivate()` | `abandonAudioFocusRequest`, cannot fail | `setActive(false, .notifyOthersOnDeactivation)` | **asymmetric failure** — iOS rejects with `isBusy` if still playing |\n| `interruption` `begin` / `end` | `AUDIOFOCUS_LOSS*` / `AUDIOFOCUS_GAIN` | `.began` / `.ended` | **parity** |\n| `type: 'duck'` | `AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK` (needs `willPauseWhenDucked`) | **never** | **ceiling** — `AVAudioSession.InterruptionType` has two cases and AVFAudio posts nothing for \"you are being ducked\". iOS ducking is applied *to* your audio and you are not told, which matches Android's default; what iOS has no equivalent for is the opt-*out* the `speech` preset asks for |\n| `permanent: true` | `AUDIOFOCUS_LOSS` | media-services loss/reset only | **ceiling** — `.began` carries no permanence, which is knowable only later from `.shouldResume`. The one iOS condition that does set it is a media-services failure, which destroys the session and has no `.ended`; the category is re-applied automatically when services return |\n| `shouldResume` | `AUDIOFOCUS_GAIN` after a transient loss | `.shouldResume` | **parity** |\n| `becomingNoisy` | `ACTION_AUDIO_BECOMING_NOISY` | `oldDeviceUnavailable` route change | **parity** |\n| `routeChange` reason | `newDeviceAvailable` / `oldDeviceUnavailable` only | all eight | **ceiling** — Android has no route-change notification; the nearest signal is [`AudioDeviceCallback`](https://developer.android.com/reference/android/media/AudioDeviceCallback), whose whole surface is devices added and removed |\n| listener delivery window | from subscription for `becomingNoisy` and `routeChange`; while focus is held for `interruption` | from subscription | **`interruption` differs** — `OnAudioFocusChangeListener` is a field of the focus request, so before `requestAudioFocus` the system has nobody to call |\n\n## Implementation notes\n\n- **Android** — `AudioFocusRequest` on API 26+, the documented\n  `requestAudioFocus(listener, streamType, durationHint)` path on API 24–25. No\n  `androidx.media` or media3 dependency. `ACTION_AUDIO_BECOMING_NOISY` and the\n  `AudioDeviceCallback` are registered on the *application* context for the union\n  of \"the session is active\" and \"something is subscribed\", and released when\n  neither holds.\n- **iOS** — `AVAudioSession.sharedInstance()`, with\n  `setActive(false, options: .notifyOthersOnDeactivation)` on `deactivate()`. The\n  notification observers are installed by `configure()`, by `activate()` and by\n  adding any listener, so no ordering of the three leaves a stream silent.\n  `oldDeviceUnavailable` route changes are also surfaced as `becomingNoisy`, so\n  the JS contract is identical on both platforms.\n\n## Also exported\n\n| Group | Exports |\n|---|---|\n| Config | `AudioSessionConfig`, `IosAudioSessionConfig`, `AndroidAudioSessionConfig`; the iOS unions `IosAudioSessionCategory`, `IosAudioSessionMode`, `IosAudioSessionCategoryOption`, `IosRouteSharingPolicy`; the Android unions `AndroidAudioUsage`, `AndroidAudioContentType`, `AndroidAudioFocusGain` — every member is the platform constant's own name, so the platform documentation applies verbatim |\n| Events | `AudioSessionEventMap`, `AudioSessionEventName`, `AudioSessionInterruptionEvent`, `AudioSessionRouteChangeEvent`, `AudioInterruptionType` — `'duck' \\| 'pause'` |\n| Wiring | `WireAudioSessionOptions`, `AudioSessionPlayerLike` (the structural player the wiring accepts), `AudioSessionApi` |\n| Native layer | `RnMediaAudioSession`, `NativeInterruptionEvent`, `NativeRouteChangeEvent` |\n| Factory and route reasons | `createAudioSession` (the factory `AudioSession` wraps), `AudioRouteChangeReason` |\n\n## Development\n\n```sh\nnpm run codegen    # nitrogen + bob build\nnpm run typecheck  # tsc --noEmit (strict)\nnpm test           # vitest\n```\n","readmeFilename":"README.md","_rev":"1-ced883cdbb8c6ec5b7b8ac2e8857f0e3"}