@umituz/react-native-sound 1.2.32 → 1.2.34

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/package.json +1 -1
  2. package/src/application/SoundCommands.ts +233 -0
  3. package/src/application/SoundEvents.ts +101 -0
  4. package/src/application/SoundService.ts +185 -0
  5. package/src/application/interfaces/IAudioService.ts +1 -1
  6. package/src/application/interfaces/ISoundCache.ts +4 -6
  7. package/src/domain/value-objects.ts +128 -0
  8. package/src/index.ts +35 -26
  9. package/src/infrastructure/AudioConfig.ts +0 -1
  10. package/src/infrastructure/AudioRepository.ts +216 -0
  11. package/src/presentation/SoundStore.ts +156 -0
  12. package/src/presentation/useSound.ts +183 -0
  13. package/src/types.ts +1 -2
  14. package/src/utils.ts +167 -5
  15. package/src/application/SoundServiceFacade.ts +0 -112
  16. package/src/application/presenters/SoundPresenter.ts +0 -78
  17. package/src/application/use-cases/BaseUseCase.ts +0 -36
  18. package/src/application/use-cases/ClearCacheUseCase.ts +0 -16
  19. package/src/application/use-cases/PauseSoundUseCase.ts +0 -26
  20. package/src/application/use-cases/PlaySoundUseCase.ts +0 -106
  21. package/src/application/use-cases/PreloadSoundUseCase.ts +0 -56
  22. package/src/application/use-cases/ResumeSoundUseCase.ts +0 -25
  23. package/src/application/use-cases/SeekSoundUseCase.ts +0 -26
  24. package/src/application/use-cases/SetRateUseCase.ts +0 -30
  25. package/src/application/use-cases/SetVolumeUseCase.ts +0 -30
  26. package/src/application/use-cases/StopSoundUseCase.ts +0 -27
  27. package/src/domain/entities/SoundState.ts +0 -83
  28. package/src/domain/value-objects/PlaybackPosition.ts +0 -30
  29. package/src/domain/value-objects/Rate.ts +0 -26
  30. package/src/domain/value-objects/SoundId.ts +0 -22
  31. package/src/domain/value-objects/SoundSource.ts +0 -30
  32. package/src/domain/value-objects/Volume.ts +0 -26
  33. package/src/infrastructure/ExpoAudioService.ts +0 -88
  34. package/src/infrastructure/SoundCache.ts +0 -70
  35. package/src/presentation/hooks/useSound.ts +0 -47
  36. package/src/presentation/store.ts +0 -58
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@umituz/react-native-sound",
3
- "version": "1.2.32",
3
+ "version": "1.2.34",
4
4
  "description": "Universal sound playback and caching library for React Native apps",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -0,0 +1,233 @@
1
+ /**
2
+ * Generic Command Processor - Eliminates Use Case Duplication
3
+ * Replaces 8 separate use case files with single command pattern
4
+ */
5
+
6
+ import type { SoundSourceValue } from '../domain/value-objects';
7
+ import type { IAudioService, PlaybackStatus } from './interfaces/IAudioService';
8
+ import type { ISoundCache } from './interfaces/ISoundCache';
9
+ import { SoundEvents } from './SoundEvents';
10
+ import { SoundError } from '../domain/errors/SoundError';
11
+ import { Logger } from '../infrastructure/Logger';
12
+
13
+ // ===== Command Definitions =====
14
+ export type SoundCommand =
15
+ | { type: 'PLAY'; payload: PlayCommand }
16
+ | { type: 'PAUSE'; payload: { sound: unknown } }
17
+ | { type: 'RESUME'; payload: { sound: unknown } }
18
+ | { type: 'STOP'; payload: { sound: unknown } }
19
+ | { type: 'SEEK'; payload: { sound: unknown; positionMillis: number } }
20
+ | { type: 'SET_VOLUME'; payload: { sound: unknown; volume: number } }
21
+ | { type: 'SET_RATE'; payload: { sound: unknown; rate: number } }
22
+ | { type: 'PRELOAD'; payload: PreloadCommand }
23
+ | { type: 'UNLOAD'; payload: void }
24
+ | { type: 'CLEAR_CACHE'; payload: void };
25
+
26
+ export interface PlayCommand {
27
+ id: string;
28
+ source: SoundSourceValue;
29
+ volume?: number;
30
+ rate?: number;
31
+ isLooping?: boolean;
32
+ positionMillis?: number;
33
+ }
34
+
35
+ export interface PreloadCommand {
36
+ id: string;
37
+ source: SoundSourceValue;
38
+ volume?: number;
39
+ rate?: number;
40
+ isLooping?: boolean;
41
+ }
42
+
43
+ // ===== Generic Command Processor =====
44
+ export class SoundCommandProcessor {
45
+ private currentSound: unknown | null = null;
46
+ private currentId: string | null = null;
47
+ private currentStatusUpdater: ((status: PlaybackStatus) => void) | null = null;
48
+ private isPlaying = false;
49
+
50
+ constructor(
51
+ private readonly audioService: IAudioService,
52
+ private readonly cache: ISoundCache,
53
+ private readonly events: SoundEvents
54
+ ) {}
55
+
56
+ async execute(command: SoundCommand): Promise<void> {
57
+ try {
58
+ await this.handleCommand(command);
59
+ } catch (error) {
60
+ if (error instanceof SoundError) throw error;
61
+ throw SoundError.playbackFailed(error);
62
+ }
63
+ }
64
+
65
+ private async handleCommand(command: SoundCommand): Promise<void> {
66
+ switch (command.type) {
67
+ case 'PLAY':
68
+ await this.handlePlay(command.payload);
69
+ break;
70
+ case 'PAUSE':
71
+ await this.handlePause(command.payload);
72
+ break;
73
+ case 'RESUME':
74
+ await this.handleResume(command.payload);
75
+ break;
76
+ case 'STOP':
77
+ await this.handleStop(command.payload);
78
+ break;
79
+ case 'SEEK':
80
+ await this.handleSeek(command.payload);
81
+ break;
82
+ case 'SET_VOLUME':
83
+ await this.handleSetVolume(command.payload);
84
+ break;
85
+ case 'SET_RATE':
86
+ await this.handleSetRate(command.payload);
87
+ break;
88
+ case 'PRELOAD':
89
+ await this.handlePreload(command.payload);
90
+ break;
91
+ case 'UNLOAD':
92
+ await this.handleUnload();
93
+ break;
94
+ case 'CLEAR_CACHE':
95
+ await this.handleClearCache();
96
+ break;
97
+ }
98
+ }
99
+
100
+ // ===== Command Handlers =====
101
+ private async handlePlay(payload: PlayCommand): Promise<void> {
102
+ const { id, source, volume = 1.0, rate = 1.0, isLooping = false, positionMillis } = payload;
103
+
104
+ // Toggle if same sound
105
+ if (this.currentId === id && this.currentSound && this.isPlaying) {
106
+ await this.audioService.pause(this.currentSound);
107
+ this.isPlaying = false;
108
+ this.events.emit('PLAYBACK_STOPPED');
109
+ return;
110
+ }
111
+
112
+ // Cleanup current
113
+ await this.unloadCurrent();
114
+
115
+ // Try cache first
116
+ const cached = this.cache.get(id);
117
+ if (cached) {
118
+ this.currentSound = cached.sound;
119
+ this.cache.delete(id).catch((error) => Logger.warn('Failed to delete from cache', error));
120
+ } else {
121
+ const statusUpdater = (status: PlaybackStatus) => {
122
+ this.events.emit('STATUS_UPDATE', status);
123
+ this.isPlaying = status.isPlaying;
124
+ };
125
+
126
+ const { sound } = await this.audioService.createSound(
127
+ source,
128
+ { shouldPlay: true, isLooping, volume, rate, positionMillis },
129
+ statusUpdater
130
+ );
131
+
132
+ this.currentSound = sound;
133
+ this.currentStatusUpdater = statusUpdater;
134
+ }
135
+
136
+ await this.audioService.play(this.currentSound);
137
+ this.currentId = id;
138
+ this.isPlaying = true;
139
+
140
+ this.events.emit('PLAYBACK_STARTED', {
141
+ id,
142
+ source,
143
+ volume,
144
+ rate,
145
+ isLooping,
146
+ });
147
+ }
148
+
149
+ private async handlePause(payload: { sound: unknown }): Promise<void> {
150
+ if (!payload.sound) return;
151
+ await this.audioService.pause(payload.sound);
152
+ this.isPlaying = false;
153
+ this.events.emit('PLAYBACK_STOPPED');
154
+ }
155
+
156
+ private async handleResume(payload: { sound: unknown }): Promise<void> {
157
+ if (!payload.sound) return;
158
+ await this.audioService.play(payload.sound);
159
+ this.isPlaying = true;
160
+ }
161
+
162
+ private async handleStop(payload: { sound: unknown }): Promise<void> {
163
+ if (!payload.sound) return;
164
+ await this.audioService.stop(payload.sound);
165
+ this.isPlaying = false;
166
+ this.events.emit('PLAYBACK_STOPPED');
167
+ this.events.emit('STATE_RESET');
168
+ }
169
+
170
+ private async handleSeek(payload: { sound: unknown; positionMillis: number }): Promise<void> {
171
+ if (!payload.sound) return;
172
+ await this.audioService.setPosition(payload.sound, payload.positionMillis);
173
+ }
174
+
175
+ private async handleSetVolume(payload: { sound: unknown; volume: number }): Promise<void> {
176
+ if (!payload.sound) return;
177
+ await this.audioService.setVolume(payload.sound, payload.volume);
178
+ this.events.emit('VOLUME_CHANGED', payload.volume);
179
+ }
180
+
181
+ private async handleSetRate(payload: { sound: unknown; rate: number }): Promise<void> {
182
+ if (!payload.sound) return;
183
+ await this.audioService.setRate(payload.sound, payload.rate);
184
+ this.events.emit('RATE_CHANGED', payload.rate);
185
+ }
186
+
187
+ private async handlePreload(payload: PreloadCommand): Promise<void> {
188
+ const { id, source, volume = 1.0, rate = 1.0, isLooping = false } = payload;
189
+
190
+ if (this.cache.has(id)) return;
191
+
192
+ const { sound } = await this.audioService.createSound(
193
+ source,
194
+ { shouldPlay: false, isLooping, volume, rate },
195
+ () => {}
196
+ );
197
+
198
+ await this.cache.set(id, { sound, source, loadedAt: Date.now() });
199
+ }
200
+
201
+ private async handleUnload(): Promise<void> {
202
+ await this.unloadCurrent();
203
+ this.events.emit('STATE_RESET');
204
+ }
205
+
206
+ private async handleClearCache(): Promise<void> {
207
+ await this.cache.clear();
208
+ }
209
+
210
+ // ===== Private Helpers =====
211
+ private async unloadCurrent(): Promise<void> {
212
+ if (this.currentSound) {
213
+ try {
214
+ await this.audioService.unload(this.currentSound);
215
+ } catch (error) {
216
+ Logger.warn('Failed to unload sound', error);
217
+ }
218
+ }
219
+ this.currentStatusUpdater = null;
220
+ this.currentSound = null;
221
+ this.currentId = null;
222
+ this.isPlaying = false;
223
+ }
224
+
225
+ // ===== Public API =====
226
+ getCurrentSound(): unknown | null {
227
+ return this.currentSound;
228
+ }
229
+
230
+ getCurrentId(): string | null {
231
+ return this.currentId;
232
+ }
233
+ }
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Event Bus - Eliminates Presenter Pattern
3
+ * Direct communication between commands and store
4
+ */
5
+
6
+ import type { PlaybackStatus } from './interfaces/IAudioService';
7
+ import type { SoundSourceValue } from '../domain/value-objects';
8
+ import { Logger } from '../infrastructure/Logger';
9
+
10
+ // ===== Event Types =====
11
+ export type SoundEvent =
12
+ | { type: 'PLAYBACK_STARTED'; payload: PlaybackStartedEvent }
13
+ | { type: 'PLAYBACK_STOPPED' }
14
+ | { type: 'STATUS_UPDATE'; payload: PlaybackStatus }
15
+ | { type: 'VOLUME_CHANGED'; payload: number }
16
+ | { type: 'RATE_CHANGED'; payload: number }
17
+ | { type: 'STATE_RESET' }
18
+ | { type: 'ERROR'; payload: string };
19
+
20
+ export interface PlaybackStartedEvent {
21
+ id: string;
22
+ source: SoundSourceValue;
23
+ volume: number;
24
+ rate: number;
25
+ isLooping: boolean;
26
+ }
27
+
28
+ // ===== Event Bus Implementation =====
29
+ type EventListener = (payload: unknown) => void;
30
+
31
+ export class SoundEvents {
32
+ private listeners = new Map<string, Set<EventListener>>();
33
+
34
+ on(eventType: string, listener: EventListener): () => void {
35
+ if (!this.listeners.has(eventType)) {
36
+ this.listeners.set(eventType, new Set());
37
+ }
38
+ const listeners = this.listeners.get(eventType);
39
+ if (listeners) {
40
+ listeners.add(listener);
41
+ }
42
+
43
+ // Return unsubscribe function
44
+ return () => {
45
+ this.off(eventType, listener);
46
+ };
47
+ }
48
+
49
+ off(eventType: string, listener: EventListener): void {
50
+ const eventListeners = this.listeners.get(eventType);
51
+ if (eventListeners) {
52
+ eventListeners.delete(listener);
53
+ }
54
+ }
55
+
56
+ emit<T = unknown>(eventType: string, payload?: T): void {
57
+ const eventListeners = this.listeners.get(eventType);
58
+ if (eventListeners) {
59
+ eventListeners.forEach((listener) => {
60
+ try {
61
+ listener(payload as T);
62
+ } catch (error) {
63
+ Logger.error(`Error in event listener for ${eventType}`, error);
64
+ }
65
+ });
66
+ }
67
+ }
68
+
69
+ clear(): void {
70
+ this.listeners.clear();
71
+ }
72
+
73
+ // ===== Convenience Methods =====
74
+ emitPlaybackStarted(event: PlaybackStartedEvent): void {
75
+ this.emit('PLAYBACK_STARTED', event);
76
+ }
77
+
78
+ emitPlaybackStopped(): void {
79
+ this.emit('PLAYBACK_STOPPED');
80
+ }
81
+
82
+ emitStatusUpdate(status: PlaybackStatus): void {
83
+ this.emit('STATUS_UPDATE', status);
84
+ }
85
+
86
+ emitVolumeChanged(volume: number): void {
87
+ this.emit('VOLUME_CHANGED', volume);
88
+ }
89
+
90
+ emitRateChanged(rate: number): void {
91
+ this.emit('RATE_CHANGED', rate);
92
+ }
93
+
94
+ emitStateReset(): void {
95
+ this.emit('STATE_RESET');
96
+ }
97
+
98
+ emitError(error: string): void {
99
+ this.emit('ERROR', error);
100
+ }
101
+ }
@@ -0,0 +1,185 @@
1
+ /**
2
+ * Unified Sound Service - Combines Facade + Repository Pattern
3
+ * Single entry point for all sound operations
4
+ */
5
+
6
+ import type { SoundSourceValue } from '../domain/value-objects';
7
+ import type { PlaybackOptions } from '../types';
8
+ import { AudioConfig } from '../infrastructure/AudioConfig';
9
+ import { AudioRepository } from '../infrastructure/AudioRepository';
10
+ import { SoundCommandProcessor, type SoundCommand, type PlayCommand, type PreloadCommand } from './SoundCommands';
11
+ import { SoundEvents } from './SoundEvents';
12
+ import { Logger } from '../infrastructure/Logger';
13
+
14
+ export class SoundService {
15
+ private static instance: SoundService | null = null;
16
+ private repository: AudioRepository;
17
+ private commandProcessor: SoundCommandProcessor;
18
+ private events: SoundEvents;
19
+
20
+ private constructor() {
21
+ this.events = new SoundEvents();
22
+ this.repository = new AudioRepository(this.events);
23
+ this.commandProcessor = new SoundCommandProcessor(
24
+ this.repository.audioService,
25
+ this.repository.cache,
26
+ this.events
27
+ );
28
+
29
+ // Initialize audio config
30
+ AudioConfig.configure().catch((error) => Logger.warn('Failed to configure audio', error));
31
+ }
32
+
33
+ static getInstance(): SoundService {
34
+ if (!this.instance) {
35
+ this.instance = new SoundService();
36
+ }
37
+ return this.instance;
38
+ }
39
+
40
+ // ===== Public API =====
41
+
42
+ /**
43
+ * Play a sound
44
+ */
45
+ async play(id: string, source: SoundSourceValue, options?: PlaybackOptions): Promise<void> {
46
+ const command: SoundCommand = {
47
+ type: 'PLAY',
48
+ payload: {
49
+ id,
50
+ source,
51
+ volume: options?.volume,
52
+ rate: options?.rate,
53
+ isLooping: options?.isLooping,
54
+ positionMillis: options?.positionMillis,
55
+ },
56
+ };
57
+ await this.commandProcessor.execute(command);
58
+ }
59
+
60
+ /**
61
+ * Pause playback
62
+ */
63
+ async pause(): Promise<void> {
64
+ const sound = this.commandProcessor.getCurrentSound();
65
+ await this.commandProcessor.execute({ type: 'PAUSE', payload: { sound } });
66
+ }
67
+
68
+ /**
69
+ * Resume playback
70
+ */
71
+ async resume(): Promise<void> {
72
+ const sound = this.commandProcessor.getCurrentSound();
73
+ await this.commandProcessor.execute({ type: 'RESUME', payload: { sound } });
74
+ }
75
+
76
+ /**
77
+ * Stop playback
78
+ */
79
+ async stop(): Promise<void> {
80
+ const sound = this.commandProcessor.getCurrentSound();
81
+ await this.commandProcessor.execute({ type: 'STOP', payload: { sound } });
82
+ }
83
+
84
+ /**
85
+ * Seek to position
86
+ */
87
+ async seek(positionMillis: number): Promise<void> {
88
+ const sound = this.commandProcessor.getCurrentSound();
89
+ await this.commandProcessor.execute({
90
+ type: 'SEEK',
91
+ payload: { sound, positionMillis },
92
+ });
93
+ }
94
+
95
+ /**
96
+ * Set volume
97
+ */
98
+ async setVolume(volume: number): Promise<void> {
99
+ const sound = this.commandProcessor.getCurrentSound();
100
+ await this.commandProcessor.execute({
101
+ type: 'SET_VOLUME',
102
+ payload: { sound, volume },
103
+ });
104
+ }
105
+
106
+ /**
107
+ * Set rate
108
+ */
109
+ async setRate(rate: number): Promise<void> {
110
+ const sound = this.commandProcessor.getCurrentSound();
111
+ await this.commandProcessor.execute({
112
+ type: 'SET_RATE',
113
+ payload: { sound, rate },
114
+ });
115
+ }
116
+
117
+ /**
118
+ * Preload a sound
119
+ */
120
+ async preload(id: string, source: SoundSourceValue, options?: PlaybackOptions): Promise<void> {
121
+ const command: SoundCommand = {
122
+ type: 'PRELOAD',
123
+ payload: {
124
+ id,
125
+ source,
126
+ volume: options?.volume,
127
+ rate: options?.rate,
128
+ isLooping: options?.isLooping,
129
+ },
130
+ };
131
+ await this.commandProcessor.execute(command);
132
+ }
133
+
134
+ /**
135
+ * Unload current sound
136
+ */
137
+ async unload(): Promise<void> {
138
+ await this.commandProcessor.execute({ type: 'UNLOAD', payload: undefined });
139
+ }
140
+
141
+ /**
142
+ * Clear cache
143
+ */
144
+ async clearCache(): Promise<void> {
145
+ await this.commandProcessor.execute({ type: 'CLEAR_CACHE', payload: undefined });
146
+ }
147
+
148
+ /**
149
+ * Check if sound is cached
150
+ */
151
+ isCached(id: string): boolean {
152
+ return this.repository.cache.has(id);
153
+ }
154
+
155
+ /**
156
+ * Get current playing id
157
+ */
158
+ getCurrentId(): string | null {
159
+ return this.commandProcessor.getCurrentId();
160
+ }
161
+
162
+ /**
163
+ * Subscribe to events
164
+ */
165
+ on(eventType: string, listener: (payload: unknown) => void): () => void {
166
+ return this.events.on(eventType, listener);
167
+ }
168
+
169
+ /**
170
+ * Cleanup resources
171
+ */
172
+ async dispose(): Promise<void> {
173
+ await this.unload();
174
+ await this.clearCache();
175
+ this.events.clear();
176
+ }
177
+
178
+ /**
179
+ * Get events instance (for internal use by useSound hook)
180
+ * @internal
181
+ */
182
+ getEvents(): SoundEvents {
183
+ return this.events;
184
+ }
185
+ }
@@ -3,7 +3,7 @@
3
3
  */
4
4
 
5
5
  import type { AVPlaybackStatus } from 'expo-av';
6
- import { SoundSourceValue } from '../../domain/value-objects/SoundSource';
6
+ import type { SoundSourceValue } from '../../domain/value-objects';
7
7
 
8
8
  export interface PlaybackStatus {
9
9
  isLoaded: boolean;
@@ -2,7 +2,7 @@
2
2
  * Sound Cache Interface
3
3
  */
4
4
 
5
- import { SoundSourceValue } from '../../domain/value-objects/SoundSource';
5
+ import type { SoundSourceValue } from '../../domain/value-objects';
6
6
 
7
7
  export interface CachedSound {
8
8
  sound: unknown;
@@ -12,10 +12,8 @@ export interface CachedSound {
12
12
 
13
13
  export interface ISoundCache {
14
14
  get(id: string): CachedSound | undefined;
15
- set(id: string, cached: CachedSound): void;
16
- delete(id: string): void;
15
+ set(id: string, cached: CachedSound): Promise<void>;
16
+ delete(id: string): Promise<void>;
17
17
  has(id: string): boolean;
18
- clear(): void;
19
- cleanExpired(): void;
20
- enforceLimit(): void;
18
+ clear(): Promise<void>;
21
19
  }
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Value Objects - Consolidated with Generic Base Class
3
+ * Eliminates duplication across multiple files
4
+ */
5
+
6
+ // ===== Generic Base Value Object =====
7
+ export abstract class ValueObject<T> {
8
+ protected readonly value: T;
9
+
10
+ constructor(value: T, validator?: (value: T) => void) {
11
+ if (validator) validator(value);
12
+ this.value = this.transform(value);
13
+ }
14
+
15
+ protected abstract transform(value: T): T;
16
+
17
+ getValue(): T {
18
+ return this.value;
19
+ }
20
+
21
+ equals(other: ValueObject<T>): boolean {
22
+ return other instanceof this.constructor && this.value === other.value;
23
+ }
24
+
25
+ toString(): string {
26
+ return String(this.value);
27
+ }
28
+ }
29
+
30
+ // ===== Sound ID =====
31
+ export class SoundId extends ValueObject<string> {
32
+ protected transform(value: string): string {
33
+ if (typeof value !== 'string' || value.trim().length === 0) {
34
+ throw new Error('Sound id must be a non-empty string');
35
+ }
36
+ return value.trim();
37
+ }
38
+
39
+ override toString(): string {
40
+ return this.value;
41
+ }
42
+ }
43
+
44
+ // ===== Sound Source =====
45
+ export type SoundSourceValue = number | { uri: string; headers?: Record<string, string> };
46
+
47
+ export class SoundSource extends ValueObject<SoundSourceValue> {
48
+ protected transform(value: SoundSourceValue): SoundSourceValue {
49
+ if (value === null || value === undefined) {
50
+ throw new Error('Sound source cannot be null or undefined');
51
+ }
52
+ return value;
53
+ }
54
+
55
+ isLocal(): boolean {
56
+ return typeof this.value === 'number';
57
+ }
58
+
59
+ isRemote(): boolean {
60
+ return typeof this.value === 'object' && 'uri' in this.value;
61
+ }
62
+ }
63
+
64
+ // ===== Volume (0.0 - 1.0) =====
65
+ export class Volume extends ValueObject<number> {
66
+ private readonly MIN = 0;
67
+ private readonly MAX = 1;
68
+
69
+ protected transform(value: number): number {
70
+ if (!Number.isFinite(value)) return 1.0;
71
+ return Math.max(this.MIN, Math.min(this.MAX, value));
72
+ }
73
+ }
74
+
75
+ // ===== Playback Rate (0.5 - 2.0) =====
76
+ export class Rate extends ValueObject<number> {
77
+ private readonly MIN = 0.5;
78
+ private readonly MAX = 2.0;
79
+
80
+ protected transform(value: number): number {
81
+ if (!Number.isFinite(value)) return 1.0;
82
+ return Math.max(this.MIN, Math.min(this.MAX, value));
83
+ }
84
+ }
85
+
86
+ // ===== Playback Position =====
87
+ export class PlaybackPosition extends ValueObject<number> {
88
+ constructor(value: number, private readonly duration?: number) {
89
+ super(value);
90
+ }
91
+
92
+ protected transform(value: number): number {
93
+ if (!Number.isFinite(value)) {
94
+ throw new Error('Position must be a finite number');
95
+ }
96
+ return this.duration ? Math.max(0, Math.min(this.duration, value)) : value;
97
+ }
98
+
99
+ getDuration(): number | undefined {
100
+ return this.duration;
101
+ }
102
+ }
103
+
104
+ // ===== Factory for Batch Creation =====
105
+ export class SoundValueObjects {
106
+ static create(params: {
107
+ id: string;
108
+ source: SoundSourceValue;
109
+ volume?: number;
110
+ rate?: number;
111
+ position?: number;
112
+ duration?: number;
113
+ }): {
114
+ id: SoundId;
115
+ source: SoundSource;
116
+ volume: Volume;
117
+ rate: Rate;
118
+ position?: PlaybackPosition;
119
+ } {
120
+ return {
121
+ id: new SoundId(params.id),
122
+ source: new SoundSource(params.source),
123
+ volume: new Volume(params.volume ?? 1.0),
124
+ rate: new Rate(params.rate ?? 1.0),
125
+ position: params.position ? new PlaybackPosition(params.position, params.duration) : undefined,
126
+ };
127
+ }
128
+ }