@readium/navigator 2.4.0-alpha.8 → 2.4.0-beta.1

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 (54) hide show
  1. package/dist/index.js +1809 -1055
  2. package/dist/index.umd.cjs +170 -23
  3. package/package.json +10 -10
  4. package/src/Navigator.ts +55 -1
  5. package/src/audio/AudioNavigator.ts +497 -0
  6. package/src/audio/AudioPoolManager.ts +120 -0
  7. package/src/audio/engine/AudioEngine.ts +26 -10
  8. package/src/audio/engine/PreservePitchProcessor.js +149 -0
  9. package/src/audio/engine/PreservePitchWorklet.ts +79 -0
  10. package/src/audio/engine/WebAudioEngine.ts +558 -259
  11. package/src/audio/index.ts +3 -1
  12. package/src/audio/preferences/AudioDefaults.ts +43 -0
  13. package/src/audio/preferences/AudioPreferences.ts +54 -0
  14. package/src/audio/preferences/AudioPreferencesEditor.ts +123 -0
  15. package/src/audio/preferences/AudioSettings.ts +36 -0
  16. package/src/audio/preferences/index.ts +4 -0
  17. package/src/epub/EpubNavigator.ts +2 -2
  18. package/src/epub/frame/FrameBlobBuilder.ts +33 -84
  19. package/src/epub/frame/FramePoolManager.ts +23 -18
  20. package/src/epub/fxl/FXLFrameManager.ts +4 -11
  21. package/src/epub/fxl/FXLFramePoolManager.ts +22 -26
  22. package/src/epub/preferences/EpubPreferences.ts +4 -4
  23. package/src/injection/Injector.ts +5 -5
  24. package/src/preferences/Configurable.ts +2 -3
  25. package/src/preferences/PreferencesEditor.ts +1 -1
  26. package/src/preferences/Types.ts +19 -0
  27. package/src/webpub/WebPubNavigator.ts +1 -2
  28. package/src/webpub/preferences/WebPubPreferences.ts +3 -3
  29. package/types/src/Navigator.d.ts +46 -0
  30. package/types/src/audio/AudioNavigator.d.ts +79 -0
  31. package/types/src/audio/AudioPoolManager.d.ts +52 -0
  32. package/types/src/audio/engine/AudioEngine.d.ts +21 -7
  33. package/types/src/audio/engine/PreservePitchWorklet.d.ts +18 -0
  34. package/types/src/audio/engine/WebAudioEngine.d.ts +52 -7
  35. package/types/src/audio/index.d.ts +2 -0
  36. package/types/src/audio/preferences/AudioDefaults.d.ts +21 -0
  37. package/types/src/audio/preferences/AudioPreferences.d.ts +23 -0
  38. package/types/src/audio/preferences/AudioPreferencesEditor.d.ts +19 -0
  39. package/types/src/audio/preferences/AudioSettings.d.ts +24 -0
  40. package/types/src/audio/preferences/index.d.ts +4 -0
  41. package/types/src/epub/EpubNavigator.d.ts +2 -2
  42. package/types/src/epub/frame/FrameBlobBuilder.d.ts +3 -6
  43. package/types/src/epub/fxl/FXLFrameManager.d.ts +0 -2
  44. package/types/src/epub/preferences/EpubPreferences.d.ts +2 -2
  45. package/types/src/preferences/Configurable.d.ts +2 -3
  46. package/types/src/preferences/PreferencesEditor.d.ts +1 -1
  47. package/types/src/preferences/Types.d.ts +3 -0
  48. package/types/src/webpub/WebPubNavigator.d.ts +2 -2
  49. package/types/src/webpub/preferences/WebPubPreferences.d.ts +2 -2
  50. package/LICENSE +0 -28
  51. package/src/divina/DivinaNavigator.ts +0 -0
  52. package/src/divina/index.ts +0 -0
  53. package/types/src/divina/DivinaNavigator.d.ts +0 -0
  54. package/types/src/divina/index.d.ts +0 -0
@@ -0,0 +1,120 @@
1
+ import { Publication } from "@readium/shared";
2
+ import { WebAudioEngine } from "./engine/WebAudioEngine";
3
+
4
+ export class AudioPoolManager {
5
+ private preloadedElements: Map<string, HTMLAudioElement> = new Map();
6
+ private _audioEngine: WebAudioEngine;
7
+
8
+ constructor(audioEngine: WebAudioEngine) {
9
+ this._audioEngine = audioEngine;
10
+ }
11
+
12
+ get audioEngine(): WebAudioEngine {
13
+ return this._audioEngine;
14
+ }
15
+
16
+ /**
17
+ * Sets the current audio by href, using preloaded element if available or loading otherwise,
18
+ * and preloads adjacent tracks.
19
+ * @param href The URL of the audio resource.
20
+ * @param publication The publication containing the reading order.
21
+ * @param currentIndex The current track index.
22
+ * @param direction The navigation direction ('forward' or 'backward').
23
+ */
24
+ setCurrentAudio(href: string, publication: Publication, currentIndex: number, direction: 'forward' | 'backward'): void {
25
+ // When Web Audio is active, preloaded elements lack crossOrigin="anonymous"
26
+ // and cannot be connected to MediaElementAudioSourceNode, so bypass the pool.
27
+ const preloadedElement = !this.audioEngine.isWebAudioActive ? this.get(href) : undefined;
28
+ if (preloadedElement) {
29
+ this.audioEngine.setMediaElement(preloadedElement);
30
+ this.clear(href);
31
+ } else {
32
+ this.clear(href);
33
+ this.audioEngine.loadAudio(href);
34
+ }
35
+ this.preloadAdjacent(publication, currentIndex, direction);
36
+ }
37
+ preload(href: string): void {
38
+ if (this.preloadedElements.has(href)) {
39
+ return; // Already preloaded
40
+ }
41
+
42
+ const audioElement = document.createElement("audio");
43
+ audioElement.preload = "auto";
44
+ audioElement.src = href;
45
+ audioElement.load(); // Start buffering
46
+
47
+ this.preloadedElements.set(href, audioElement);
48
+ }
49
+
50
+ /**
51
+ * Retrieves a preloaded audio element by URL.
52
+ * @param href The URL of the audio resource.
53
+ * @returns The preloaded HTMLAudioElement, or undefined if not preloaded.
54
+ */
55
+ get(href: string): HTMLAudioElement | undefined {
56
+ return this.preloadedElements.get(href);
57
+ }
58
+
59
+ /**
60
+ * Removes a preloaded element from the pool.
61
+ * @param href The URL of the audio resource.
62
+ */
63
+ clear(href: string): void {
64
+ this.preloadedElements.delete(href);
65
+ }
66
+
67
+ /**
68
+ * Preloads the next track in the reading order.
69
+ * @param publication The publication containing the reading order.
70
+ * @param currentIndex The current track index.
71
+ */
72
+ preloadNext(publication: Publication, currentIndex: number): void {
73
+ const nextIndex = currentIndex + 1;
74
+ if (nextIndex < publication.readingOrder.items.length) {
75
+ const nextLink = publication.readingOrder.items[nextIndex];
76
+ if (nextLink.href) {
77
+ this.preload(nextLink.href);
78
+ }
79
+ }
80
+ }
81
+
82
+ /**
83
+ * Preloads the previous track in the reading order.
84
+ * @param publication The publication containing the reading order.
85
+ * @param currentIndex The current track index.
86
+ */
87
+ preloadPrevious(publication: Publication, currentIndex: number): void {
88
+ const prevIndex = currentIndex - 1;
89
+ if (prevIndex >= 0) {
90
+ const prevLink = publication.readingOrder.items[prevIndex];
91
+ if (prevLink.href) {
92
+ this.preload(prevLink.href);
93
+ }
94
+ }
95
+ }
96
+
97
+ /**
98
+ * Preloads adjacent tracks (previous and next) for smoother navigation.
99
+ * @param publication The publication containing the reading order.
100
+ * @param currentIndex The current track index.
101
+ * @param direction The navigation direction ('forward' or 'backward').
102
+ */
103
+ preloadAdjacent(publication: Publication, currentIndex: number, direction: 'forward' | 'backward' = 'forward'): void {
104
+ if (direction === 'forward') {
105
+ this.preloadNext(publication, currentIndex);
106
+ this.preloadPrevious(publication, currentIndex);
107
+ } else {
108
+ this.preloadPrevious(publication, currentIndex);
109
+ this.preloadNext(publication, currentIndex);
110
+ }
111
+ }
112
+
113
+ /**
114
+ * Destroys the pool by stopping the engine and clearing all preloaded elements.
115
+ */
116
+ destroy(): void {
117
+ this.audioEngine.stop();
118
+ this.preloadedElements.clear();
119
+ }
120
+ }
@@ -1,6 +1,3 @@
1
- import { Locator } from '@readium/shared';
2
- import { Publication } from '@readium/shared';
3
-
4
1
  /**
5
2
  * Initial state of the audio engine playback.
6
3
  */
@@ -49,10 +46,11 @@ export interface AudioEngine {
49
46
  playback: Playback;
50
47
 
51
48
  /**
52
- * Plays the audio resource at the given locator.
49
+ * Loads the audio resource at the given URL.
50
+ * @param url The URL of the audio resource.
53
51
  */
54
- playLocator(publication: Publication, locator: Locator): Promise<void>;
55
-
52
+ loadAudio(url: string): void;
53
+
56
54
  /**
57
55
  * Adds an event listener to the audio engine.
58
56
  * @param event The event name to listen.
@@ -66,12 +64,12 @@ export interface AudioEngine {
66
64
  * @param callback Callback function to be removed.
67
65
  */
68
66
  off(event: string, callback: (data: any) => void): void;
69
-
67
+
70
68
  /**
71
- * Loads the audio resource at the given URL.
72
- * @param url The URL of the audio resource.
69
+ * Sets the media element for playback, enabling use of preloaded elements from the pool.
70
+ * @param element The HTML audio element to use for playback.
73
71
  */
74
- loadAudio(url: string): void;
72
+ setMediaElement(element: HTMLAudioElement): void;
75
73
 
76
74
  /**
77
75
  * Plays the current audio resource.
@@ -97,6 +95,19 @@ export interface AudioEngine {
97
95
  * Returns the duration of the audio resource.
98
96
  */
99
97
  duration(): number;
98
+
99
+ /**
100
+ * Sets the volume of the audio resource.
101
+ * @param volume The volume to set, in the range [0, 1].
102
+ */
103
+ setVolume(volume: number): void;
104
+
105
+ /**
106
+ * Sets the playback rate of the audio resource.
107
+ * @param rate The playback rate to set.
108
+ * @param preservePitch Whether to preserve pitch when changing playback rate.
109
+ */
110
+ setPlaybackRate(rate: number, preservePitch: boolean): void;
100
111
 
101
112
  /**
102
113
  * Returns whether the audio resource is currently playing.
@@ -108,6 +119,11 @@ export interface AudioEngine {
108
119
  */
109
120
  isPaused(): boolean;
110
121
 
122
+ /**
123
+ * Returns the HTML media element used for playback.
124
+ */
125
+ getMediaElement(): HTMLMediaElement;
126
+
111
127
  /**
112
128
  * Returns whether the audio resource is currently stopped.
113
129
  */
@@ -0,0 +1,149 @@
1
+ // PreservePitchProcessor.js
2
+ // AudioWorklet processor for pitch preservation via pitch shifting
3
+
4
+ class PreservePitchProcessor extends AudioWorkletProcessor {
5
+ constructor() {
6
+ super();
7
+ this.bufferSize = 1024;
8
+ this.hopSize = 256;
9
+ this.overlap = this.bufferSize - this.hopSize;
10
+ this.inputBuffer = new Float32Array(this.bufferSize);
11
+ this.outputBuffer = new Float32Array(this.bufferSize);
12
+ this.window = new Float32Array(this.bufferSize);
13
+ this.bufferIndex = 0;
14
+ this.pitchFactor = 1.0;
15
+
16
+ // Hann window
17
+ for (let i = 0; i < this.bufferSize; i++) {
18
+ this.window[i] = 0.5 * (1 - Math.cos(2 * Math.PI * i / this.bufferSize));
19
+ }
20
+
21
+ this.port.onmessage = (event) => {
22
+ if (event.data.type === 'setPitchFactor') {
23
+ this.pitchFactor = event.data.factor;
24
+ }
25
+ };
26
+ }
27
+
28
+ process(inputs, outputs) {
29
+ const input = inputs[0];
30
+ const output = outputs[0];
31
+
32
+ if (!input || !output) return true;
33
+
34
+ const inputChannel = input[0];
35
+ const outputChannel = output[0];
36
+
37
+ // Accumulate input
38
+ for (let i = 0; i < inputChannel.length; i++) {
39
+ this.inputBuffer[this.bufferIndex] = inputChannel[i];
40
+ this.bufferIndex++;
41
+
42
+ if (this.bufferIndex >= this.bufferSize) {
43
+ // Process buffer
44
+ this.processBuffer();
45
+ // Output hopSize samples
46
+ for (let j = 0; j < this.hopSize; j++) {
47
+ outputChannel[j] = this.outputBuffer[j];
48
+ }
49
+ // Shift buffer
50
+ for (let j = 0; j < this.overlap; j++) {
51
+ this.inputBuffer[j] = this.inputBuffer[j + this.hopSize];
52
+ }
53
+ this.bufferIndex = this.overlap;
54
+ // Clear output buffer for next
55
+ this.outputBuffer.fill(0);
56
+ }
57
+ }
58
+
59
+ return true;
60
+ }
61
+
62
+ processBuffer() {
63
+ // Apply window
64
+ let windowed = new Float32Array(this.bufferSize);
65
+ for (let i = 0; i < this.bufferSize; i++) {
66
+ windowed[i] = this.inputBuffer[i] * this.window[i];
67
+ }
68
+
69
+ // FFT
70
+ let fftResult = this.fft(windowed);
71
+
72
+ // Pitch shift
73
+ let shifted = this.pitchShift(fftResult, this.pitchFactor);
74
+
75
+ // IFFT
76
+ let ifftResult = this.ifft(shifted);
77
+
78
+ // Overlap-add
79
+ for (let i = 0; i < this.bufferSize; i++) {
80
+ this.outputBuffer[i] += ifftResult[i] * this.window[i];
81
+ }
82
+ }
83
+
84
+ pitchShift(fft, factor) {
85
+ let N = fft.length;
86
+ let result = new Array(N).fill(null).map(() => ({ real: 0, imag: 0 }));
87
+ for (let k = 0; k < N / 2; k++) {
88
+ let newK = Math.round(k * factor);
89
+ if (newK < N / 2) {
90
+ result[newK] = fft[k];
91
+ result[N - newK] = fft[N - k]; // symmetric for real input
92
+ }
93
+ }
94
+ return result;
95
+ }
96
+
97
+ fft(input) {
98
+ let N = input.length;
99
+ if (N <= 1) return [{ real: input[0] || 0, imag: 0 }];
100
+ if ((N & (N - 1)) !== 0) throw new Error('N must be power of 2');
101
+
102
+ let even = this.fft(input.filter((_, i) => i % 2 === 0));
103
+ let odd = this.fft(input.filter((_, i) => i % 2 === 1));
104
+
105
+ let result = new Array(N);
106
+ for (let k = 0; k < N / 2; k++) {
107
+ let t = odd[k];
108
+ let angle = -2 * Math.PI * k / N;
109
+ let twiddle = { real: Math.cos(angle), imag: Math.sin(angle) };
110
+ let twiddled = {
111
+ real: t.real * twiddle.real - t.imag * twiddle.imag,
112
+ imag: t.real * twiddle.imag + t.imag * twiddle.real
113
+ };
114
+ result[k] = {
115
+ real: even[k].real + twiddled.real,
116
+ imag: even[k].imag + twiddled.imag
117
+ };
118
+ result[k + N / 2] = {
119
+ real: even[k].real - twiddled.real,
120
+ imag: even[k].imag - twiddled.imag
121
+ };
122
+ }
123
+ return result;
124
+ }
125
+
126
+ ifft(input) {
127
+ let N = input.length;
128
+ // Conjugate
129
+ let conj = input.map(c => ({ real: c.real, imag: -c.imag }));
130
+ // FFT
131
+ let fftConj = this.fft(conj.map(c => c.real)); // wait, fft expects real array
132
+ // FFT on complex is needed, but simplify
133
+ // For simplicity, implement IFFT similarly
134
+ let result = new Float32Array(N);
135
+ for (let n = 0; n < N; n++) {
136
+ let sumReal = 0, sumImag = 0;
137
+ for (let k = 0; k < N; k++) {
138
+ let angle = 2 * Math.PI * k * n / N;
139
+ let c = input[k];
140
+ sumReal += c.real * Math.cos(angle) - c.imag * Math.sin(angle);
141
+ sumImag += c.real * Math.sin(angle) + c.imag * Math.cos(angle);
142
+ }
143
+ result[n] = sumReal / N;
144
+ }
145
+ return result;
146
+ }
147
+ }
148
+
149
+ registerProcessor('preserve-pitch-processor', PreservePitchProcessor);
@@ -0,0 +1,79 @@
1
+ interface PreservePitchWorkletOptions {
2
+ ctx: AudioContext;
3
+ mediaElement?: HTMLMediaElement;
4
+ pitchFactor?: number;
5
+ modulePath?: string;
6
+ }
7
+
8
+ import processorCode from './PreservePitchProcessor.js?raw';
9
+
10
+ export class PreservePitchWorklet {
11
+ mediaElement: HTMLMediaElement | null = null;
12
+ source: MediaElementAudioSourceNode | null = null;
13
+ ctx: AudioContext;
14
+ workletNode: AudioWorkletNode | null = null;
15
+ url: string | null = null;
16
+
17
+ static async createWorklet(options: PreservePitchWorkletOptions): Promise<PreservePitchWorklet> {
18
+ const { ctx, mediaElement, pitchFactor, modulePath } = options;
19
+ const worklet = new PreservePitchWorklet(ctx);
20
+
21
+ try {
22
+ if (modulePath) {
23
+ await ctx.audioWorklet.addModule(modulePath);
24
+ } else {
25
+ const blob = new Blob([processorCode], { type: 'text/javascript' });
26
+ worklet.url = URL.createObjectURL(blob);
27
+ await ctx.audioWorklet.addModule(worklet.url);
28
+ }
29
+ } catch (err) {
30
+ worklet.destroy();
31
+ throw new Error(`Error adding module: ${err}`);
32
+ }
33
+
34
+ try {
35
+ worklet.workletNode = new AudioWorkletNode(ctx, 'preserve-pitch-processor');
36
+
37
+ if (pitchFactor) {
38
+ worklet.updatePitchFactor(pitchFactor);
39
+ }
40
+
41
+ if (mediaElement) {
42
+ const source = ctx.createMediaElementSource(mediaElement);
43
+ source.connect(worklet.workletNode);
44
+ worklet.mediaElement = mediaElement;
45
+ worklet.source = source;
46
+ }
47
+ } catch (err) {
48
+ worklet.destroy();
49
+ throw new Error(`Error creating worklet node: ${err}`);
50
+ }
51
+
52
+ return worklet;
53
+ }
54
+
55
+ constructor(ctx: AudioContext) {
56
+ this.ctx = ctx;
57
+ }
58
+
59
+ updatePitchFactor(factor: number): void {
60
+ if (this.workletNode) {
61
+ this.workletNode.port.postMessage({ type: 'setPitchFactor', factor });
62
+ }
63
+ }
64
+
65
+ destroy(): void {
66
+ if (this.workletNode) {
67
+ this.workletNode.disconnect();
68
+ this.workletNode = null;
69
+ }
70
+ if (this.source) {
71
+ this.source.disconnect();
72
+ this.source = null;
73
+ }
74
+ if (this.url) {
75
+ URL.revokeObjectURL(this.url);
76
+ this.url = null;
77
+ }
78
+ }
79
+ }