carta1 1.3.0 → 1.5.0
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.
- package/README.md +51 -3
- package/bin/build-wme.js +69 -0
- package/bin/cli.js +4 -29
- package/codec/boundary/container.js +27 -5
- package/codec/boundary/wme.js +189 -0
- package/dist/carta1-worker.min.js +1 -1
- package/dist/carta1-worker.min.js.map +1 -1
- package/dist/carta1.min.js +1 -1
- package/dist/carta1.min.js.map +1 -1
- package/dist/carta1.wme +0 -0
- package/dist/wme/index.js +2 -0
- package/dist/wme/index.js.map +1 -0
- package/package.json +5 -4
package/README.md
CHANGED
|
@@ -58,7 +58,7 @@ signed 16-bit PCM WAVE output.
|
|
|
58
58
|
| `-j, --json` | Write AEA metadata and frame structure as JSON. |
|
|
59
59
|
| `-q, --quiet` | Suppress normal output and progress. |
|
|
60
60
|
| `-f, --force` | Overwrite an existing output file. |
|
|
61
|
-
| `-t, --title <title>` | Set
|
|
61
|
+
| `-t, --title <title>` | Set a UTF-8 AEA title (up to 255 encoded bytes). |
|
|
62
62
|
| `-b, --bias <value>` | Set the bit-allocation bias; defaults to `1.0`. |
|
|
63
63
|
| `-m, --modes <modes>` | Fix low, mid, and high block modes, such as `0,0,0`. |
|
|
64
64
|
| `-V, --version` | Print the Carta1 version. |
|
|
@@ -184,8 +184,8 @@ npm test # Run the test suite once
|
|
|
184
184
|
npm run test:watch # Re-run tests while editing
|
|
185
185
|
npm run lint # Check JavaScript and formatting
|
|
186
186
|
npm run format # Apply repository formatting
|
|
187
|
-
npm run build # Build the
|
|
188
|
-
npm run check # Run lint, tests, and
|
|
187
|
+
npm run build # Build browser bundles and the WME plugin
|
|
188
|
+
npm run check # Run lint, tests, builds, and packaged WME checks
|
|
189
189
|
```
|
|
190
190
|
|
|
191
191
|
Run `npm run check` before submitting a change. Pull requests and pushes run
|
|
@@ -201,3 +201,51 @@ implementation used to understand the ATRAC1 format and codec behavior.
|
|
|
201
201
|
## License
|
|
202
202
|
|
|
203
203
|
ISC
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
## Experimental Web MiniDisc encoder plugin
|
|
207
|
+
|
|
208
|
+
`npm run build` also emits `dist/carta1.wme`, alongside the three existing
|
|
209
|
+
browser bundles. GitHub Releases attach the WME, all three JavaScript bundles,
|
|
210
|
+
and their source maps. The npm package includes the same build outputs. Release
|
|
211
|
+
CDN links are pinned to that release's version.
|
|
212
|
+
|
|
213
|
+
**Carta1 (ATRAC1, experimental)** is an ATRAC1 codec for standard MD SP, not
|
|
214
|
+
ATRAC3 LP2/LP4 or ATRAC3PLUS Hi-MD. Current Web MiniDisc API v1 does not support
|
|
215
|
+
software ATRAC1 output: its SP modes request PCM for the recorder to encode in
|
|
216
|
+
hardware. Installing this plugin does not enable Carta1 recording in the
|
|
217
|
+
unmodified app. All current API v1 codecs (PCM, MP3, AT3, A3+) are explicitly
|
|
218
|
+
reported as unsupported so there is no silent hardware-encoding fallback.
|
|
219
|
+
|
|
220
|
+
### Proposed host extension for testing
|
|
221
|
+
|
|
222
|
+
The archive uses the standard API v1 SAR container and default encoder class,
|
|
223
|
+
with the usual `init`, `deinit`, `getSupportFor`, `transcode`, and progress hooks.
|
|
224
|
+
It adds two **proposed, nonstandard** export codec names, using the host's
|
|
225
|
+
existing recording-mode names:
|
|
226
|
+
|
|
227
|
+
| `exportParams.format` | PCM channels | Output |
|
|
228
|
+
| --- | --- | --- |
|
|
229
|
+
| `{ codec: 'SPS', bitrate: 292 }` | 2 | ATRAC1 stereo SP |
|
|
230
|
+
| `{ codec: 'SPM', bitrate: 146 }` | 1 | ATRAC1 mono SP |
|
|
231
|
+
|
|
232
|
+
Bitrates are nominal kbps; ATRAC1 uses 212-byte sound units per channel per
|
|
233
|
+
512 samples at 44.1 kHz. The host's built-in FFmpeg decodes/resamples sources
|
|
234
|
+
and optionally applies ReplayGain; the existing Carta1 worker encodes them.
|
|
235
|
+
`transcode` returns raw sound units **without the 2048-byte AEA header**.
|
|
236
|
+
Stereo sound units alternate left/right. Canonical priming and drain units
|
|
237
|
+
are retained; gapless is not advertised and AEA timeline metadata is omitted.
|
|
238
|
+
|
|
239
|
+
A host implementing this proposal must extend its export-codec interface,
|
|
240
|
+
preserve SPS/SPM through capability checks and transcoding instead of mapping
|
|
241
|
+
them to PCM, and route the result to a compatible pre-encoded ATRAC1 transfer
|
|
242
|
+
path (such as a supported device's homebrew SP uploader). It must handle channel
|
|
243
|
+
mode, transfer framing/padding, and device capabilities there. Ordinary SP PCM
|
|
244
|
+
upload must never receive these compressed bytes. Neither the proposed names
|
|
245
|
+
nor device transfer compatibility are part of the current WMD API contract;
|
|
246
|
+
the WMD team can revise them when implementing support. No host changes are
|
|
247
|
+
included here.
|
|
248
|
+
|
|
249
|
+
`npm run test:wme` exercises the built archive and its bundled worker for both
|
|
250
|
+
proposed formats, comparing the returned raw bytes with `encodeAeaPcm` across
|
|
251
|
+
independent tracks. Lifecycle and failure tests run in the normal test suite.
|
package/bin/build-wme.js
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/** Package the bundled API v1 module and existing worker as a WME SAR archive.
|
|
2
|
+
* Wire format: asivery/webminidisc src/services/audio/apiv1/external-archive.ts
|
|
3
|
+
* (9637bed5), with big-endian lengths and metadata.json as the first entry.
|
|
4
|
+
*/
|
|
5
|
+
import { readFile, writeFile } from 'node:fs/promises'
|
|
6
|
+
import { fileURLToPath } from 'node:url'
|
|
7
|
+
|
|
8
|
+
// The host imports blobs; relative source-map URLs cannot resolve there.
|
|
9
|
+
function bundledScript(contents) {
|
|
10
|
+
return Buffer.from(
|
|
11
|
+
Buffer.from(contents)
|
|
12
|
+
.toString('utf8')
|
|
13
|
+
.replace(/^\/\/# sourceMappingURL=.*$/gm, '')
|
|
14
|
+
)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function createWmeArchive(metadata, index, worker) {
|
|
18
|
+
const parts = [Buffer.from('SARFile')]
|
|
19
|
+
const subMagic = Buffer.from('Web MiniDisc Dynamic Encoder')
|
|
20
|
+
const length = Buffer.alloc(2)
|
|
21
|
+
length.writeUInt16BE(subMagic.length)
|
|
22
|
+
parts.push(length, subMagic)
|
|
23
|
+
for (const [name, contents] of [
|
|
24
|
+
['metadata.json', Buffer.from(JSON.stringify(metadata))],
|
|
25
|
+
['index.js', bundledScript(index)],
|
|
26
|
+
['worker.js', bundledScript(worker)],
|
|
27
|
+
]) {
|
|
28
|
+
const filename = Buffer.from(name)
|
|
29
|
+
const nameLength = Buffer.alloc(2)
|
|
30
|
+
nameLength.writeUInt16BE(filename.length)
|
|
31
|
+
const size = Buffer.alloc(4)
|
|
32
|
+
size.writeUInt32BE(contents.length)
|
|
33
|
+
parts.push(nameLength, filename, size, contents)
|
|
34
|
+
}
|
|
35
|
+
const archive = Buffer.concat(parts)
|
|
36
|
+
if (archive.length > 50 * 1024 * 1024) {
|
|
37
|
+
throw new RangeError('WME archive exceeds the host 50 MiB limit')
|
|
38
|
+
}
|
|
39
|
+
return archive
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function main() {
|
|
43
|
+
const root = new URL('../', import.meta.url)
|
|
44
|
+
const pkg = JSON.parse(await readFile(new URL('package.json', root), 'utf8'))
|
|
45
|
+
const [index, worker] = await Promise.all([
|
|
46
|
+
readFile(new URL('dist/wme/index.js', root)),
|
|
47
|
+
readFile(new URL(`dist/${pkg.name}-worker.min.js`, root)),
|
|
48
|
+
])
|
|
49
|
+
const archive = createWmeArchive(
|
|
50
|
+
{
|
|
51
|
+
encoderId: pkg.name,
|
|
52
|
+
userFriendlyName: 'Carta1 (ATRAC1, experimental)',
|
|
53
|
+
description:
|
|
54
|
+
'ATRAC1 codec for standard MD SP stereo/mono. Experimental: requires host support for software ATRAC1; current Web MiniDisc API v1 is unsupported.',
|
|
55
|
+
version: pkg.version,
|
|
56
|
+
encoderDependencies: [],
|
|
57
|
+
apiVersion: '1',
|
|
58
|
+
},
|
|
59
|
+
index,
|
|
60
|
+
worker
|
|
61
|
+
)
|
|
62
|
+
const output = new URL(`dist/${pkg.name}.wme`, root)
|
|
63
|
+
await writeFile(output, archive)
|
|
64
|
+
console.log(`Built ${fileURLToPath(output)} (${archive.length} bytes)`)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
|
|
68
|
+
await main()
|
|
69
|
+
}
|
package/bin/cli.js
CHANGED
|
@@ -21,14 +21,13 @@ import wav from 'wav'
|
|
|
21
21
|
|
|
22
22
|
import { AudioProcessor } from '../codec/boundary/processor.js'
|
|
23
23
|
import { AeaReader } from '../codec/boundary/reader.js'
|
|
24
|
-
import { AeaFile } from '../codec/boundary/container.js'
|
|
24
|
+
import { AeaFile, validateAeaTitle } from '../codec/boundary/container.js'
|
|
25
25
|
import { deserializeFrame } from '../codec/syntax/frame.js'
|
|
26
26
|
import { EncoderOptions } from '../codec/core/options.js'
|
|
27
27
|
import {
|
|
28
28
|
SAMPLE_RATE,
|
|
29
29
|
SAMPLES_PER_FRAME,
|
|
30
30
|
BITRATE_PER_CHANNEL,
|
|
31
|
-
AEA_TITLE_SIZE,
|
|
32
31
|
AEA_HEADER_SIZE,
|
|
33
32
|
SOUND_UNIT_SIZE,
|
|
34
33
|
} from '../codec/core/constants.js'
|
|
@@ -48,7 +47,7 @@ function formatTime(seconds) {
|
|
|
48
47
|
}
|
|
49
48
|
|
|
50
49
|
/**
|
|
51
|
-
* Validate AEA title for
|
|
50
|
+
* Validate an AEA title for its fixed-width UTF-8 field.
|
|
52
51
|
*
|
|
53
52
|
* @param {string} title - Title to validate
|
|
54
53
|
* @returns {Object} Validation result
|
|
@@ -56,31 +55,7 @@ function formatTime(seconds) {
|
|
|
56
55
|
* @returns {string} [returns.error] - Error message if invalid
|
|
57
56
|
*/
|
|
58
57
|
function validateTitle(title) {
|
|
59
|
-
|
|
60
|
-
return { valid: true }
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
// Check ASCII encoding
|
|
64
|
-
for (let i = 0; i < title.length; i++) {
|
|
65
|
-
const charCode = title.charCodeAt(i)
|
|
66
|
-
if (charCode > 127) {
|
|
67
|
-
return {
|
|
68
|
-
valid: false,
|
|
69
|
-
error: `Title contains non-ASCII character at position ${i}: "${title[i]}"`,
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
// Check length (AEA_TITLE_SIZE includes null terminator)
|
|
75
|
-
const maxLength = AEA_TITLE_SIZE - 1
|
|
76
|
-
if (title.length > maxLength) {
|
|
77
|
-
return {
|
|
78
|
-
valid: false,
|
|
79
|
-
error: `Title is too long (${title.length} chars). Maximum allowed: ${maxLength} characters`,
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
return { valid: true }
|
|
58
|
+
return validateAeaTitle(title)
|
|
84
59
|
}
|
|
85
60
|
|
|
86
61
|
/**
|
|
@@ -703,7 +678,7 @@ async function main() {
|
|
|
703
678
|
.option('-f, --force', 'Overwrite output file if it exists')
|
|
704
679
|
.option(
|
|
705
680
|
'-t, --title <title>',
|
|
706
|
-
'Custom title for AEA file metadata (encoding only)'
|
|
681
|
+
'Custom UTF-8 title for AEA file metadata (encoding only)'
|
|
707
682
|
)
|
|
708
683
|
.option(
|
|
709
684
|
'-b, --bias <value>',
|
|
@@ -14,6 +14,29 @@ import {
|
|
|
14
14
|
} from '../core/constants.js'
|
|
15
15
|
import { AEA_MAGIC } from '../core/tables.js'
|
|
16
16
|
|
|
17
|
+
const UTF8_ENCODER = new TextEncoder()
|
|
18
|
+
const UTF8_DECODER = new TextDecoder()
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Validate a title for the fixed-width AEA title field.
|
|
22
|
+
*
|
|
23
|
+
* @param {string} title
|
|
24
|
+
* @returns {{valid: boolean, error?: string}}
|
|
25
|
+
*/
|
|
26
|
+
export function validateAeaTitle(title) {
|
|
27
|
+
const byteLength = UTF8_ENCODER.encode(title).length
|
|
28
|
+
const maxByteLength = AEA_TITLE_SIZE - 1
|
|
29
|
+
|
|
30
|
+
if (byteLength > maxByteLength) {
|
|
31
|
+
return {
|
|
32
|
+
valid: false,
|
|
33
|
+
error: `Title is too long (${byteLength} UTF-8 bytes). Maximum allowed: ${maxByteLength} bytes`,
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return { valid: true }
|
|
38
|
+
}
|
|
39
|
+
|
|
17
40
|
/**
|
|
18
41
|
* AEA file-format handler.
|
|
19
42
|
*/
|
|
@@ -39,10 +62,9 @@ export class AeaFile {
|
|
|
39
62
|
const view = new DataView(header.buffer)
|
|
40
63
|
header.set(AEA_MAGIC, 0)
|
|
41
64
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
AEA_TITLE_OFFSET
|
|
65
|
+
UTF8_ENCODER.encodeInto(
|
|
66
|
+
title,
|
|
67
|
+
header.subarray(AEA_TITLE_OFFSET, AEA_TITLE_OFFSET + AEA_TITLE_SIZE - 1)
|
|
46
68
|
)
|
|
47
69
|
view.setUint32(AEA_FRAME_COUNT_OFFSET, frameCount, true)
|
|
48
70
|
header[AEA_CHANNEL_COUNT_OFFSET] = channelCount
|
|
@@ -81,7 +103,7 @@ export class AeaFile {
|
|
|
81
103
|
const titleEnd = header.indexOf(0, AEA_TITLE_OFFSET)
|
|
82
104
|
const titleLength =
|
|
83
105
|
titleEnd === -1 ? AEA_TITLE_SIZE : titleEnd - AEA_TITLE_OFFSET
|
|
84
|
-
const title =
|
|
106
|
+
const title = UTF8_DECODER.decode(
|
|
85
107
|
header.subarray(AEA_TITLE_OFFSET, AEA_TITLE_OFFSET + titleLength)
|
|
86
108
|
)
|
|
87
109
|
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
/** Experimental ATRAC1 host extension; see README's WME contract.
|
|
2
|
+
* API v1's current codec union does not include SPS/SPM. No PCM fallback.
|
|
3
|
+
*/
|
|
4
|
+
import { AeaFile } from './container.js'
|
|
5
|
+
import { AEA_HEADER_SIZE, SOUND_UNIT_SIZE } from '../core/constants.js'
|
|
6
|
+
|
|
7
|
+
const PROFILES = {
|
|
8
|
+
SPS: { bitrate: 292, channels: 2 },
|
|
9
|
+
SPM: { bitrate: 146, channels: 1 },
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export default class CartaWmeEncoder {
|
|
13
|
+
static customConstructionParameters = []
|
|
14
|
+
|
|
15
|
+
constructor(api) {
|
|
16
|
+
this.api = api
|
|
17
|
+
this.ffmpeg = api.getBuiltinEncoder('ffmpeg')
|
|
18
|
+
this.workerURL = null
|
|
19
|
+
this.worker = null
|
|
20
|
+
this.rejectJob = null
|
|
21
|
+
this.busy = false
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async init() {
|
|
25
|
+
if (this.workerURL) return
|
|
26
|
+
try {
|
|
27
|
+
await this.ffmpeg.init()
|
|
28
|
+
this.workerURL = URL.createObjectURL(
|
|
29
|
+
new Blob([this.api.getOwnFile('worker.js')], {
|
|
30
|
+
type: 'text/javascript',
|
|
31
|
+
})
|
|
32
|
+
)
|
|
33
|
+
} catch (error) {
|
|
34
|
+
await this.ffmpeg.deinit()
|
|
35
|
+
throw error
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async deinit() {
|
|
40
|
+
this.rejectJob?.(new Error('Encoder was stopped'))
|
|
41
|
+
this.worker?.terminate()
|
|
42
|
+
this.worker = null
|
|
43
|
+
if (this.workerURL) URL.revokeObjectURL(this.workerURL)
|
|
44
|
+
this.workerURL = null
|
|
45
|
+
await this.ffmpeg.deinit()
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
getSupportFor(codec) {
|
|
49
|
+
if (Object.hasOwn(PROFILES, codec)) {
|
|
50
|
+
return {
|
|
51
|
+
state: 'perfect',
|
|
52
|
+
gapless: false,
|
|
53
|
+
bitrates: [PROFILES[codec].bitrate],
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return { state: 'unsupported', gapless: false }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
getUserFriendyStageName(stage) {
|
|
60
|
+
if (stage === 'carta') return 'Encoding ATRAC1...'
|
|
61
|
+
if (stage === 'ffmpeg') return 'Preparing audio...'
|
|
62
|
+
return this.ffmpeg.getUserFriendyStageName(stage)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async transcode(source, sourceFileName, exportParams, progress) {
|
|
66
|
+
if (!this.workerURL) throw new Error('Encoder is not initialized')
|
|
67
|
+
if (this.busy) throw new Error('Encoder is already processing a track')
|
|
68
|
+
const { codec, bitrate } = exportParams.format
|
|
69
|
+
if (
|
|
70
|
+
!Object.hasOwn(PROFILES, codec) ||
|
|
71
|
+
bitrate !== PROFILES[codec].bitrate
|
|
72
|
+
) {
|
|
73
|
+
throw new RangeError(
|
|
74
|
+
'Unsupported carta1 recording format (experimental SPS/SPM host extension required)'
|
|
75
|
+
)
|
|
76
|
+
}
|
|
77
|
+
this.busy = true
|
|
78
|
+
try {
|
|
79
|
+
return await this.encodeAtrac(
|
|
80
|
+
source,
|
|
81
|
+
sourceFileName,
|
|
82
|
+
exportParams,
|
|
83
|
+
progress
|
|
84
|
+
)
|
|
85
|
+
} finally {
|
|
86
|
+
this.busy = false
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async encodeAtrac(source, sourceFileName, exportParams, progress) {
|
|
91
|
+
const { channels } = PROFILES[exportParams.format.codec]
|
|
92
|
+
const extension = sourceFileName.split('.').pop()
|
|
93
|
+
const inputName = `carta1-input.${/^[a-z0-9]+$/i.test(extension) ? extension : 'audio'}`
|
|
94
|
+
const outputName = 'carta1-pcm.f32'
|
|
95
|
+
const process = this.ffmpeg.ffmpegProcess
|
|
96
|
+
try {
|
|
97
|
+
progress?.({ stage: 'ffmpeg', progress: 0, total: 1 })
|
|
98
|
+
await process.write(inputName, source)
|
|
99
|
+
// Preserve host resampling/ReplayGain, overriding its default stereo for SPM.
|
|
100
|
+
const command = this.ffmpeg.createFfmpegParams(
|
|
101
|
+
exportParams,
|
|
102
|
+
'f32le',
|
|
103
|
+
`-ac ${channels} -c:a pcm_f32le`
|
|
104
|
+
)
|
|
105
|
+
await process.transcode(inputName, outputName, command)
|
|
106
|
+
const { data } = await process.read(outputName)
|
|
107
|
+
const stride = channels * 4
|
|
108
|
+
if (data.byteLength === 0 || data.byteLength % stride !== 0) {
|
|
109
|
+
throw new RangeError('Expected nonempty channel-aligned float32 PCM')
|
|
110
|
+
}
|
|
111
|
+
const view = new DataView(data.buffer, data.byteOffset, data.byteLength)
|
|
112
|
+
const samples = data.byteLength / stride
|
|
113
|
+
const pcmData = Array.from(
|
|
114
|
+
{ length: channels },
|
|
115
|
+
() => new Float32Array(samples)
|
|
116
|
+
)
|
|
117
|
+
for (let sample = 0; sample < samples; sample++) {
|
|
118
|
+
for (let channel = 0; channel < channels; channel++) {
|
|
119
|
+
pcmData[channel][sample] = view.getFloat32(
|
|
120
|
+
sample * stride + channel * 4,
|
|
121
|
+
true
|
|
122
|
+
)
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
progress?.({ stage: 'ffmpeg', progress: 1, total: 1 })
|
|
126
|
+
progress?.({ stage: 'carta', progress: 0, total: 1 })
|
|
127
|
+
const aea = await this.encodeInWorker(pcmData)
|
|
128
|
+
if (aea.byteLength <= AEA_HEADER_SIZE) {
|
|
129
|
+
throw new Error('Worker returned an incompatible ATRAC1 AEA file')
|
|
130
|
+
}
|
|
131
|
+
const info = AeaFile.parseHeader(aea.subarray(0, AEA_HEADER_SIZE))
|
|
132
|
+
const payloadLength = aea.byteLength - AEA_HEADER_SIZE
|
|
133
|
+
if (
|
|
134
|
+
info.channelCount !== channels ||
|
|
135
|
+
payloadLength % (SOUND_UNIT_SIZE * channels) !== 0 ||
|
|
136
|
+
info.frameCount * SOUND_UNIT_SIZE !== payloadLength
|
|
137
|
+
) {
|
|
138
|
+
throw new Error('Worker returned an incompatible ATRAC1 AEA file')
|
|
139
|
+
}
|
|
140
|
+
progress?.({ stage: 'carta', progress: 1, total: 1 })
|
|
141
|
+
// Proposed host contract: raw 212-byte sound units, no AEA header.
|
|
142
|
+
// Stereo units alternate L/R. Keep canonical priming and drain units.
|
|
143
|
+
return aea.slice(AEA_HEADER_SIZE)
|
|
144
|
+
} finally {
|
|
145
|
+
// FFmpeg 0.6 tracks pending FS jobs by action, not job ID. Parallel
|
|
146
|
+
// removals overwrite the resolver and leave one promise pending forever.
|
|
147
|
+
for (const filename of [inputName, outputName]) {
|
|
148
|
+
try {
|
|
149
|
+
await process.remove(filename)
|
|
150
|
+
} catch {
|
|
151
|
+
// A failed conversion may not have created both temporary files.
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async encodeInWorker(pcmData) {
|
|
158
|
+
const worker = new Worker(this.workerURL)
|
|
159
|
+
this.worker = worker
|
|
160
|
+
try {
|
|
161
|
+
const result = await new Promise((resolve, reject) => {
|
|
162
|
+
this.rejectJob = reject
|
|
163
|
+
worker.onmessage = ({ data }) => {
|
|
164
|
+
if (data.jobId !== 1) return
|
|
165
|
+
if (data.error) reject(new Error(data.error))
|
|
166
|
+
else resolve(data.result)
|
|
167
|
+
}
|
|
168
|
+
worker.onerror = (event) =>
|
|
169
|
+
reject(new Error(event.message || 'Codec worker failed'))
|
|
170
|
+
worker.onmessageerror = () =>
|
|
171
|
+
reject(new Error('Invalid codec worker response'))
|
|
172
|
+
worker.postMessage(
|
|
173
|
+
{
|
|
174
|
+
jobId: 1,
|
|
175
|
+
type: 'encode',
|
|
176
|
+
pcmData,
|
|
177
|
+
options: {},
|
|
178
|
+
},
|
|
179
|
+
pcmData.map((channel) => channel.buffer)
|
|
180
|
+
)
|
|
181
|
+
})
|
|
182
|
+
return new Uint8Array(await result.aeaBlob.arrayBuffer())
|
|
183
|
+
} finally {
|
|
184
|
+
this.rejectJob = null
|
|
185
|
+
worker.terminate()
|
|
186
|
+
this.worker = null
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|