@ossy/media-tasks 3.17.0 → 3.19.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 CHANGED
@@ -10,10 +10,13 @@ Tasks in this package are registered with `TaskService.RegisterTask()` at API st
10
10
  |---------|------------------------------|-------------|
11
11
  | `@ossy/media-tasks/tasks/resize-common-web` | file `Created` + `image/*` | Common web sizes + BlurHash → storage keys under the source id |
12
12
  | `@ossy/media-tasks/tasks/extract-colors` | file `Created` + `image/*` | Dominant color + palette JSON → storage |
13
+ | `@ossy/media-tasks/tasks/convert-common-image` | file `Created` + `image/*` (+ primary action) | First-slice format convert via **sharp**: auto-run for HEIC/HEIF/TIFF/BMP/AVIF **and** jpeg/png/webp → configurable `jpeg`/`png`/`webp` (default jpeg; skips when already the target). Manual Storage → Tasks invoke accepts any image. Inputs `format`, `quality`. |
13
14
  | `@ossy/media-tasks/tasks/visual-content-descriptors` | file `Created` + `image/*` / `video/*` | OpenRouter vision → title/description/tags/alt JSON → storage. Input `model` (config or payload); default `openai/gpt-4o-mini`, overridable per trigger. |
14
15
  | `@ossy/media-tasks/tasks/run-prompt` | file `Created` + `text/*` / `image/*` / `application/json`, or `@ossy/resources/schema/markdown` `Created` | OpenRouter chat completion. Inputs `prompt` (required), `model` (default `openai/gpt-4o-mini`), optional `temperature`. File bytes or the Markdown document `body` are the context. |
16
+ | `@ossy/media-tasks/tasks/convert-common-audio` | file `Created` + `audio/*`, or `on_action` | General audio format converter via ffmpeg: MP3 (default), AAC, OGG, WAV, AIFF, FLAC. Inputs `format`, `quality`. Skips uploads already in the target container MIME. |
17
+ | `@ossy/media-tasks/tasks/text-to-voice` | `on_action` + Markdown `Created` + file `Created` + `text/*` | OpenRouter TTS → MP3. Inputs `text` (optional when a resource supplies body/bytes), `voice` (default `en_paul_neutral`), `model` (OpenRouter slug; default `mistralai/voxtral-mini-tts-2603`). |
15
18
 
16
- All four set **`configurable: true`**. Workspaces arm them on `/automation` with their own `triggers[]` (within the ceiling above) and optional placement folders. Until armed, they do not run.
19
+ All seven set **`configurable: true`**. Workspaces arm them on `/automation` with their own `triggers[]` (within the ceiling above) and optional placement folders. Until armed, they do not run.
17
20
 
18
21
  ## Task outputs
19
22
 
@@ -24,8 +27,11 @@ All four set **`configurable: true`**. Workspaces arm them on `/automation` with
24
27
  |------|--------|---------------|
25
28
  | extract-colors | `palette` | `@ossy/media-tasks/schema/extract-colors` |
26
29
  | resize-common-web | `sizes` (+ binary keys) | `@ossy/media-tasks/schema/resize-common-web` (JSON meta placed; binaries via URL) |
30
+ | convert-common-image | `converted` (+ `image` binary) | `@ossy/media-tasks/schema/convert-common-image` (JSON meta placed; bytes at `…-image`) |
27
31
  | visual-content-descriptors | `descriptors` | `@ossy/media-tasks/schema/visual-content-descriptors` |
28
32
  | run-prompt | `completion` | `@ossy/platform/schema/text-completion` (freeform text/md — a platform schema, not feature-owned) |
33
+ | convert-common-audio | `converted` (+ `audio` binary) | `@ossy/media-tasks/schema/convert-common-audio` (JSON meta placed; bytes at `…-audio`) |
34
+ | text-to-voice | `speech` (+ `audio` MP3) | `@ossy/media-tasks/schema/text-to-voice` (JSON meta placed; bytes at `…-audio`) |
29
35
 
30
36
  See [TaskOutput.md](../../docs/concepts/TaskOutput.md).
31
37
 
@@ -55,12 +61,16 @@ export async function run ({ event, sdk, inputs }) { ... }
55
61
 
56
62
  | Id | Credentials | Used by |
57
63
  |----|-------------|---------|
58
- | `openrouter` | `OPENROUTER_API_KEY` (required); optional `OPENROUTER_SITE_URL`, `OPENROUTER_APP_TITLE`, `OPENROUTER_BASE_URL` | `visual-content-descriptors` and `run-prompt` via `integrations.get('openrouter')` |
64
+ | `openrouter` | `OPENROUTER_API_KEY` (required); optional `OPENROUTER_SITE_URL`, `OPENROUTER_APP_TITLE`, `OPENROUTER_BASE_URL` | `visual-content-descriptors`, `run-prompt`, and `text-to-voice` via `integrations.get('openrouter')` |
59
65
 
60
66
  OpenRouter is OpenAI-compatible (`baseURL` `https://openrouter.ai/api/v1`). The VCD `model` input defaults to `openai/gpt-4o-mini`. A workspace can set another slug as the instance default, and a different slug on a single trigger (folder or MIME). An invoke payload still wins.
61
67
 
62
68
  `run-prompt` sends the resolved prompt as the user message. A matching file is appended as context (text/JSON excerpt, or an image data URL). A Markdown document (`@ossy/resources/schema/markdown`) sends `content.body` instead. The completion is stored at `{resourceId}:task-run-prompt-completion` and placed only when the workspace set a folder. **Run now** invokes `@ossy/media-tasks/actions/run-prompt` with the editor values; without a resource id the completion is returned on the task run and not stored.
63
69
 
70
+ **Provider decision (#898):** TTS uses the shared `openrouter` integration (OpenRouter `/audio/speech`, OpenAI-compatible). Default model `mistralai/voxtral-mini-tts-2603` (in OpenRouter's speech catalog); legacy `tts-1` / `tts-1-hd` aliases map onto that slug. Writes an MP3 artifact. Character usage is **not** counted against the ADR 0017 LLM token cap.
71
+
72
+ `text-to-voice` resolves spoken text from (1) the `text` input, else (2) Markdown `content.body`, else (3) text-file bytes. Primary action `@ossy/media-tasks/actions/text-to-voice` supports manual Storage → Tasks invokes (epic #889 / #934).
73
+
64
74
  ## Usage metering and a monthly token cap (ADR 0017)
65
75
 
66
76
  Both OpenRouter tasks read `response.usage` and return it as `result.usage = { model, promptTokens, completionTokens, totalTokens }`. `TaskRunService` forwards this onto `MeteringService.record()` automatically (any task returning `result.usage` in this shape gets metered — no per-task wiring needed).
@@ -71,5 +81,21 @@ Before calling OpenRouter, each task calls `assertLlmUsageBudget()` (`@ossy/plat
71
81
 
72
82
  - **`sharp`** (peer) — image resizing + metadata
73
83
  - **`blurhash`** — encode compact placeholders after resize
74
- - **`openai`** (peer) — OpenAI SDK client pointed at OpenRouter
84
+ - **`openai`** (peer) — OpenAI SDK client pointed at OpenRouter (chat + TTS)
85
+ - **`ffmpeg-static`** (optional peer; also a media-tasks **devDependency** so CI/unit tests resolve a binary) — portable ffmpeg for `convert-common-audio`; otherwise `FFMPEG_PATH` or system `ffmpeg` on PATH
75
86
  - **`@ossy/platform`** — `StorageClient` for reading/writing file bytes
87
+
88
+ ## Audio output formats (`convert-common-audio`)
89
+
90
+ `convert-common-audio` converts between these container formats via ffmpeg. Inputs: `format` (default `mp3`) and `quality` 1–100 for lossy codecs (ignored for wav/aiff/flac). Auto-run on `audio/*` Created skips uploads already in the target container MIME; Storage → Tasks (`on_action`) always offers the format/quality dialog.
91
+
92
+ | Format | Codec | Content-Type | Quality mapping (input 1–100) |
93
+ |--------|-------|--------------|-------------------------------|
94
+ | `mp3` (default) | `libmp3lame` | `audio/mpeg` | VBR `q:a` 0–9 — `Math.round((1 - quality/100) * 9)` (100 → 0 best) |
95
+ | `aac` | `aac` | `audio/aac` | Bitrate 64k–320k — `64 + (quality/100) * 256` |
96
+ | `ogg` | `libvorbis` | `audio/ogg` | VBR `q:a` 1–10 — `max(1, round(quality/10))` |
97
+ | `wav` | `pcm_s16le` | `audio/wav` | Lossless PCM — quality ignored |
98
+ | `aiff` | `pcm_s16be` | `audio/aiff` | Lossless PCM — quality ignored |
99
+ | `flac` | `flac` | `audio/flac` | Lossless — fixed `compression_level` 5 |
100
+
101
+ JSON meta artifact `converted` stores `format`, `contentType`, `sourceContentType`, `quality`, `size`, `audioKey`; binary bytes live at `…-audio`. Storage → Tasks shows an inline player plus `FORMAT · quality N`. Binary Content-Type uses magic-byte sniffing (WAV/AIFF/OGG/FLAC/MP3/AAC). DJ-specific preprocessing (BPM/key, Camelot tags, EBU R128 for rekordbox/CDJ USB) is tracked separately in [#951](https://github.com/ossy-se/ossy/issues/951) (`analyze-dj-audio`, not shipped yet).
package/package.json CHANGED
@@ -1,12 +1,13 @@
1
1
  {
2
2
  "name": "@ossy/media-tasks",
3
- "version": "3.17.0",
4
- "description": "Changestream-triggered media processing tasks (image resize, AI descriptors, prompts)",
3
+ "version": "3.19.0",
4
+ "description": "Changestream-triggered media processing tasks (image resize, audio convert, AI descriptors, prompts)",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
7
7
  "module": "./src/index.js",
8
8
  "exports": {
9
- ".": "./src/index.js"
9
+ ".": "./src/index.js",
10
+ "./embed-resource": "./src/embed-resource.js"
10
11
  },
11
12
  "ossy": {
12
13
  "src": "./src"
@@ -17,15 +18,24 @@
17
18
  },
18
19
  "dependencies": {
19
20
  "@ossy/observability": "^3.0.9",
20
- "@ossy/platform": "^3.17.0",
21
- "@ossy/resources": "^3.17.0",
22
- "@ossy/schema": "^3.17.0",
21
+ "@ossy/platform": "^3.19.0",
22
+ "@ossy/resources": "^3.19.0",
23
+ "@ossy/schema": "^3.18.0",
23
24
  "blurhash": "^2.0.5"
24
25
  },
25
26
  "peerDependencies": {
27
+ "ffmpeg-static": ">=5",
26
28
  "openai": ">=4",
27
29
  "sharp": ">=0.34"
28
30
  },
31
+ "peerDependenciesMeta": {
32
+ "ffmpeg-static": {
33
+ "optional": true
34
+ }
35
+ },
36
+ "devDependencies": {
37
+ "ffmpeg-static": "^5.3.0"
38
+ },
29
39
  "publishConfig": {
30
40
  "access": "public",
31
41
  "registry": "https://registry.npmjs.org"
@@ -36,5 +46,5 @@
36
46
  ],
37
47
  "author": "Ossy <yourfriends@ossy.se> (https://ossy.se)",
38
48
  "license": "MIT",
39
- "gitHead": "91ff7dffe9b0bddcb823daee67213d2008eb64e9"
49
+ "gitHead": "0ac103001a468f56597c02cf1c6802bf7bd89b33"
40
50
  }
@@ -0,0 +1,7 @@
1
+ export const Definition = {
2
+ id: 'media-tasks',
3
+ title: 'Media Tasks',
4
+ description: 'Automated image processing tasks — extract color palettes, generate resize variants, and run visual content analysis.',
5
+ icon: 'image',
6
+ status: ['beta'],
7
+ }
@@ -0,0 +1,5 @@
1
+ export const metadata = {
2
+ id: '@ossy/media-tasks/actions/convert-common-audio',
3
+ access: 'workspace',
4
+ label: 'automation.task.convert-common-audio.title',
5
+ }
@@ -0,0 +1,243 @@
1
+ /**
2
+ * General audio format converter (#897 — generalised from DJ-specific).
3
+ *
4
+ * Converts between common audio formats via ffmpeg. DJ/PCM preprocessing
5
+ * (BPM, Camelot key, rekordbox/CDJ USB prep) is tracked separately in #951
6
+ * (`analyze-dj-audio`) — that task is not implemented in this package yet.
7
+ *
8
+ * Supported output formats: mp3, aac, ogg, wav, aiff, flac
9
+ * Default: mp3
10
+ *
11
+ * Dependency: system `ffmpeg` on PATH, or optional peer `ffmpeg-static`
12
+ * (or `FFMPEG_PATH`). No fluent-ffmpeg wrapper — spawn is enough.
13
+ */
14
+
15
+ import { spawn } from 'node:child_process'
16
+ import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
17
+ import { tmpdir } from 'node:os'
18
+ import { join } from 'node:path'
19
+
20
+ /** @type {ReadonlyArray<string>} */
21
+ export const COMMON_AUDIO_TARGET_FORMATS = Object.freeze([
22
+ 'mp3', 'aac', 'ogg', 'wav', 'aiff', 'flac',
23
+ ])
24
+
25
+ /** @type {Readonly<Record<string, string>>} */
26
+ export const COMMON_AUDIO_FORMAT_CONTENT_TYPES = Object.freeze({
27
+ mp3: 'audio/mpeg',
28
+ aac: 'audio/aac',
29
+ ogg: 'audio/ogg',
30
+ wav: 'audio/wav',
31
+ aiff: 'audio/aiff',
32
+ flac: 'audio/flac',
33
+ })
34
+
35
+ /** MIME types already matching a target container (upload auto-run skip). */
36
+ export const COMMON_AUDIO_TARGET_MIME_TYPES = Object.freeze({
37
+ mp3: Object.freeze(['audio/mpeg', 'audio/mp3']),
38
+ aac: Object.freeze(['audio/aac', 'audio/x-aac']),
39
+ ogg: Object.freeze(['audio/ogg']),
40
+ wav: Object.freeze(['audio/wav', 'audio/x-wav', 'audio/wave']),
41
+ aiff: Object.freeze(['audio/aiff', 'audio/x-aiff']),
42
+ flac: Object.freeze(['audio/flac', 'audio/x-flac']),
43
+ })
44
+
45
+ /** ffmpeg codec per output format. */
46
+ const FORMAT_CODEC = Object.freeze({
47
+ mp3: 'libmp3lame',
48
+ aac: 'aac',
49
+ ogg: 'libvorbis',
50
+ wav: 'pcm_s16le',
51
+ aiff: 'pcm_s16be',
52
+ flac: 'flac',
53
+ })
54
+
55
+ export const DEFAULT_COMMON_AUDIO_FORMAT = 'mp3'
56
+ export const DEFAULT_COMMON_AUDIO_QUALITY = 85
57
+
58
+ /**
59
+ * @param {unknown} value
60
+ * @returns {string}
61
+ */
62
+ export function normalizeCommonAudioFormat (value) {
63
+ const raw = typeof value === 'string' ? value.trim().toLowerCase() : ''
64
+ if (raw === 'aif') return 'aiff'
65
+ if (raw === 'm4a' || raw === 'mp4') return 'aac'
66
+ if (COMMON_AUDIO_TARGET_FORMATS.includes(raw)) return raw
67
+ return DEFAULT_COMMON_AUDIO_FORMAT
68
+ }
69
+
70
+ /**
71
+ * @param {unknown} value
72
+ * @returns {number}
73
+ */
74
+ export function normalizeCommonAudioQuality (value) {
75
+ const n = typeof value === 'number' ? value : Number(String(value ?? '').trim())
76
+ if (!Number.isFinite(n) || n <= 0) return DEFAULT_COMMON_AUDIO_QUALITY
77
+ return Math.max(1, Math.min(100, Math.round(n)))
78
+ }
79
+
80
+ /**
81
+ * @param {unknown} contentType
82
+ * @returns {string}
83
+ */
84
+ export function normalizeContentType (contentType) {
85
+ return String(contentType || '').split(';')[0].trim().toLowerCase()
86
+ }
87
+
88
+ /**
89
+ * @param {unknown} contentType
90
+ * @returns {boolean}
91
+ */
92
+ export function isAudioContentType (contentType) {
93
+ return normalizeContentType(contentType).startsWith('audio/')
94
+ }
95
+
96
+ /**
97
+ * @param {string} format
98
+ * @returns {string}
99
+ */
100
+ export function contentTypeForCommonAudioFormat (format) {
101
+ const normalized = normalizeCommonAudioFormat(format)
102
+ return COMMON_AUDIO_FORMAT_CONTENT_TYPES[normalized] || COMMON_AUDIO_FORMAT_CONTENT_TYPES.mp3
103
+ }
104
+
105
+ /**
106
+ * Skip when the source MIME already matches the requested target container.
107
+ *
108
+ * @param {unknown} sourceContentType
109
+ * @param {string} targetFormat
110
+ * @returns {boolean}
111
+ */
112
+ export function isAlreadyCommonAudioTargetFormat (sourceContentType, targetFormat) {
113
+ const format = normalizeCommonAudioFormat(targetFormat)
114
+ const mime = normalizeContentType(sourceContentType)
115
+ const accepted = COMMON_AUDIO_TARGET_MIME_TYPES[format] || []
116
+ return accepted.includes(mime)
117
+ }
118
+
119
+ /**
120
+ * Build ffmpeg quality flags for a given format and quality (1–100).
121
+ *
122
+ * @param {string} format
123
+ * @param {number} quality 1–100
124
+ * @returns {string[]}
125
+ */
126
+ export function qualityFlagsForFormat (format, quality) {
127
+ switch (format) {
128
+ case 'mp3': {
129
+ // VBR: q:a 0 (best) – 9 (worst). quality 100→0, quality 1→9.
130
+ const q = Math.round((1 - quality / 100) * 9)
131
+ return ['-q:a', String(q)]
132
+ }
133
+ case 'ogg': {
134
+ // q:a 0 (worst) – 10 (best). quality 100→10, quality 1→1.
135
+ const q = Math.max(1, Math.round(quality / 10))
136
+ return ['-q:a', String(q)]
137
+ }
138
+ case 'aac': {
139
+ // Map quality 1–100 → 64k–320k.
140
+ const kbps = Math.round(64 + (quality / 100) * 256)
141
+ return ['-b:a', `${kbps}k`]
142
+ }
143
+ case 'flac':
144
+ // compression_level 0 (fastest) – 12 (smallest). Default 5 is fine.
145
+ return ['-compression_level', '5']
146
+ default:
147
+ // wav / aiff — PCM, lossless, no quality flags needed.
148
+ return []
149
+ }
150
+ }
151
+
152
+ /**
153
+ * Resolve the ffmpeg binary: `FFMPEG_PATH`, optional `ffmpeg-static`, then PATH.
154
+ *
155
+ * @returns {Promise<string>}
156
+ */
157
+ export async function resolveFfmpegPath () {
158
+ if (typeof process.env.FFMPEG_PATH === 'string' && process.env.FFMPEG_PATH.trim()) {
159
+ return process.env.FFMPEG_PATH.trim()
160
+ }
161
+ try {
162
+ const mod = await import('ffmpeg-static')
163
+ const path = mod?.default || mod
164
+ if (typeof path === 'string' && path) return path
165
+ } catch {
166
+ // peer optional — fall through to system binary
167
+ }
168
+ return 'ffmpeg'
169
+ }
170
+
171
+ /**
172
+ * @param {string} ffmpegPath
173
+ * @param {string[]} args
174
+ * @returns {Promise<void>}
175
+ */
176
+ export function runFfmpeg (ffmpegPath, args) {
177
+ return new Promise((resolve, reject) => {
178
+ const child = spawn(ffmpegPath, args, { stdio: ['ignore', 'ignore', 'pipe'] })
179
+ let stderr = ''
180
+ child.stderr?.on('data', (chunk) => { stderr += String(chunk) })
181
+ child.on('error', (err) => {
182
+ reject(new Error(`[convert-common-audio] failed to spawn ffmpeg: ${err.message}`))
183
+ })
184
+ child.on('close', (code) => {
185
+ if (code === 0) { resolve(); return }
186
+ const detail = stderr.trim() || `exit ${code}`
187
+ reject(new Error(`[convert-common-audio] ffmpeg failed: ${detail}`))
188
+ })
189
+ })
190
+ }
191
+
192
+ /**
193
+ * Convert an audio buffer to the target format via ffmpeg.
194
+ *
195
+ * @param {Buffer} sourceBuffer
196
+ * @param {{ format?: string, quality?: number, ffmpegPath?: string }} [options]
197
+ * @returns {Promise<{
198
+ * buffer: Buffer,
199
+ * format: string,
200
+ * contentType: string,
201
+ * size: number,
202
+ * }>}
203
+ */
204
+ export async function convertCommonAudioBuffer (sourceBuffer, options = {}) {
205
+ if (!Buffer.isBuffer(sourceBuffer) || sourceBuffer.length === 0) {
206
+ throw new Error('[convert-common-audio] source buffer is required')
207
+ }
208
+
209
+ const format = normalizeCommonAudioFormat(options.format)
210
+ const quality = normalizeCommonAudioQuality(options.quality ?? DEFAULT_COMMON_AUDIO_QUALITY)
211
+ const ffmpegPath = options.ffmpegPath || await resolveFfmpegPath()
212
+ const codec = FORMAT_CODEC[format]
213
+
214
+ const dir = await mkdtemp(join(tmpdir(), 'ossy-common-audio-'))
215
+ const inputPath = join(dir, 'input.bin')
216
+ const outputPath = join(dir, `output.${format}`)
217
+
218
+ try {
219
+ await writeFile(inputPath, sourceBuffer)
220
+ await runFfmpeg(ffmpegPath, [
221
+ '-y',
222
+ '-hide_banner',
223
+ '-loglevel', 'error',
224
+ '-i', inputPath,
225
+ '-vn',
226
+ '-acodec', codec,
227
+ ...qualityFlagsForFormat(format, quality),
228
+ outputPath,
229
+ ])
230
+ const buffer = await readFile(outputPath)
231
+ if (!buffer.length) {
232
+ throw new Error('[convert-common-audio] ffmpeg produced an empty output')
233
+ }
234
+ return {
235
+ buffer,
236
+ format,
237
+ contentType: contentTypeForCommonAudioFormat(format),
238
+ size: buffer.length,
239
+ }
240
+ } finally {
241
+ await rm(dir, { recursive: true, force: true })
242
+ }
243
+ }
@@ -0,0 +1,41 @@
1
+ import { TASK_OUTPUT_PROVENANCE_FIELDS } from '@ossy/schema'
2
+
3
+ export default {
4
+ name: 'Convert audio format',
5
+ id: '@ossy/media-tasks/schema/convert-common-audio',
6
+ categoryName: 'Media processing',
7
+ icon: 'music',
8
+ fields: [
9
+ {
10
+ name: 'format',
11
+ type: 'text',
12
+ description: 'Output container (mp3, aac, ogg, wav, aiff, or flac)',
13
+ },
14
+ {
15
+ name: 'contentType',
16
+ type: 'text',
17
+ description: 'MIME type of the converted audio bytes',
18
+ },
19
+ {
20
+ name: 'sourceContentType',
21
+ type: 'text',
22
+ description: 'MIME type of the source upload',
23
+ },
24
+ {
25
+ name: 'quality',
26
+ type: 'number',
27
+ description: 'Encode quality 1–100 used for lossy formats',
28
+ },
29
+ {
30
+ name: 'size',
31
+ type: 'number',
32
+ description: 'Converted audio byte length',
33
+ },
34
+ {
35
+ name: 'audioKey',
36
+ type: 'text',
37
+ description: 'Storage key for the converted audio binary artifact',
38
+ },
39
+ ...TASK_OUTPUT_PROVENANCE_FIELDS,
40
+ ],
41
+ }
@@ -0,0 +1,188 @@
1
+ import { spawn } from 'node:child_process'
2
+ import { mkdtemp, readFile, rm } from 'node:fs/promises'
3
+ import { tmpdir } from 'node:os'
4
+ import { join } from 'node:path'
5
+ import {
6
+ DEFAULT_COMMON_AUDIO_FORMAT,
7
+ DEFAULT_COMMON_AUDIO_QUALITY,
8
+ contentTypeForCommonAudioFormat,
9
+ convertCommonAudioBuffer,
10
+ isAlreadyCommonAudioTargetFormat,
11
+ isAudioContentType,
12
+ normalizeCommonAudioFormat,
13
+ normalizeCommonAudioQuality,
14
+ qualityFlagsForFormat,
15
+ resolveFfmpegPath,
16
+ } from './convert-common-audio.js'
17
+
18
+ /**
19
+ * @param {string} ffmpegPath
20
+ * @param {string[]} args
21
+ * @param {string} outputPath
22
+ * @returns {Promise<Buffer>}
23
+ */
24
+ async function encodeWithFfmpeg (ffmpegPath, args, outputPath) {
25
+ await new Promise((resolve, reject) => {
26
+ const child = spawn(ffmpegPath, args, { stdio: ['ignore', 'ignore', 'pipe'] })
27
+ let stderr = ''
28
+ child.stderr?.on('data', (chunk) => { stderr += String(chunk) })
29
+ child.on('error', reject)
30
+ child.on('close', (code) => {
31
+ if (code === 0) resolve()
32
+ else reject(new Error(stderr || `exit ${code}`))
33
+ })
34
+ })
35
+ return readFile(outputPath)
36
+ }
37
+
38
+ /**
39
+ * @returns {Promise<string|null>}
40
+ */
41
+ async function resolveUsableFfmpegPath () {
42
+ const ffmpegPath = await resolveFfmpegPath()
43
+ try {
44
+ await new Promise((resolve, reject) => {
45
+ const child = spawn(ffmpegPath, ['-version'], { stdio: ['ignore', 'ignore', 'ignore'] })
46
+ child.on('error', reject)
47
+ child.on('close', (code) => (code === 0 ? resolve() : reject(new Error(`exit ${code}`))))
48
+ })
49
+ return ffmpegPath
50
+ } catch {
51
+ return null
52
+ }
53
+ }
54
+
55
+ /**
56
+ * @param {string} ffmpegPath
57
+ * @returns {Promise<Buffer>}
58
+ */
59
+ async function makeTestMp3 (ffmpegPath) {
60
+ const dir = await mkdtemp(join(tmpdir(), 'ossy-common-audio-fixture-'))
61
+ const outputPath = join(dir, 'tone.mp3')
62
+ try {
63
+ return await encodeWithFfmpeg(ffmpegPath, [
64
+ '-y', '-hide_banner', '-loglevel', 'error',
65
+ '-f', 'lavfi',
66
+ '-i', 'sine=frequency=440:duration=0.25',
67
+ '-ac', '1',
68
+ '-ar', '22050',
69
+ '-c:a', 'libmp3lame',
70
+ '-b:a', '128k',
71
+ outputPath,
72
+ ], outputPath)
73
+ } finally {
74
+ await rm(dir, { recursive: true, force: true })
75
+ }
76
+ }
77
+
78
+ describe('convert-common-audio helpers', () => {
79
+ it('normalizes format (aliases + defaults)', () => {
80
+ expect(normalizeCommonAudioFormat('MP3')).toBe('mp3')
81
+ expect(normalizeCommonAudioFormat('aif')).toBe('aiff')
82
+ expect(normalizeCommonAudioFormat('m4a')).toBe('aac')
83
+ expect(normalizeCommonAudioFormat('mp4')).toBe('aac')
84
+ expect(normalizeCommonAudioFormat('nope')).toBe(DEFAULT_COMMON_AUDIO_FORMAT)
85
+ expect(normalizeCommonAudioFormat('')).toBe(DEFAULT_COMMON_AUDIO_FORMAT)
86
+ })
87
+
88
+ it('normalizes quality', () => {
89
+ expect(normalizeCommonAudioQuality(85)).toBe(85)
90
+ expect(normalizeCommonAudioQuality(0)).toBe(DEFAULT_COMMON_AUDIO_QUALITY)
91
+ expect(normalizeCommonAudioQuality(150)).toBe(100)
92
+ expect(normalizeCommonAudioQuality('bad')).toBe(DEFAULT_COMMON_AUDIO_QUALITY)
93
+ })
94
+
95
+ it('recognizes audio MIME and already-converted sources', () => {
96
+ expect(isAudioContentType('audio/mpeg')).toBe(true)
97
+ expect(isAudioContentType('image/png')).toBe(false)
98
+ expect(isAlreadyCommonAudioTargetFormat('audio/mpeg', 'mp3')).toBe(true)
99
+ expect(isAlreadyCommonAudioTargetFormat('audio/mp3', 'mp3')).toBe(true)
100
+ expect(isAlreadyCommonAudioTargetFormat('audio/wav', 'wav')).toBe(true)
101
+ expect(isAlreadyCommonAudioTargetFormat('audio/x-wav', 'wav')).toBe(true)
102
+ expect(isAlreadyCommonAudioTargetFormat('audio/aiff', 'aiff')).toBe(true)
103
+ expect(isAlreadyCommonAudioTargetFormat('audio/flac', 'flac')).toBe(true)
104
+ expect(isAlreadyCommonAudioTargetFormat('audio/mpeg', 'wav')).toBe(false)
105
+ expect(contentTypeForCommonAudioFormat('mp3')).toBe('audio/mpeg')
106
+ expect(contentTypeForCommonAudioFormat('aiff')).toBe('audio/aiff')
107
+ expect(contentTypeForCommonAudioFormat('flac')).toBe('audio/flac')
108
+ })
109
+
110
+ it('builds correct ffmpeg quality flags', () => {
111
+ // MP3 VBR: quality 100 → q:a 0, quality 1 → q:a 9
112
+ expect(qualityFlagsForFormat('mp3', 100)).toEqual(['-q:a', '0'])
113
+ expect(qualityFlagsForFormat('mp3', 1)).toEqual(['-q:a', '9'])
114
+ // (1 - 85/100) * 9 = 1.35 → rounds to 1
115
+ expect(qualityFlagsForFormat('mp3', 85)).toEqual(['-q:a', '1'])
116
+ // OGG
117
+ expect(qualityFlagsForFormat('ogg', 100)).toEqual(['-q:a', '10'])
118
+ expect(qualityFlagsForFormat('ogg', 50)).toEqual(['-q:a', '5'])
119
+ // AAC bitrate range
120
+ const aacFlags = qualityFlagsForFormat('aac', 85)
121
+ expect(aacFlags[0]).toBe('-b:a')
122
+ // lossless: no quality flags
123
+ expect(qualityFlagsForFormat('wav', 85)).toEqual([])
124
+ expect(qualityFlagsForFormat('aiff', 85)).toEqual([])
125
+ })
126
+
127
+ it('resolves an ffmpeg binary', async () => {
128
+ const path = await resolveFfmpegPath()
129
+ expect(typeof path).toBe('string')
130
+ expect(path.length).toBeGreaterThan(0)
131
+ })
132
+ })
133
+
134
+ describe('convertCommonAudioBuffer', () => {
135
+ /** @type {string|null} */
136
+ let ffmpegPath = null
137
+
138
+ beforeAll(async () => {
139
+ ffmpegPath = await resolveUsableFfmpegPath()
140
+ })
141
+
142
+ it('converts MP3 bytes to WAV', async () => {
143
+ if (!ffmpegPath) {
144
+ throw new Error(
145
+ 'ffmpeg binary not found — install media-tasks devDependency ffmpeg-static, or set FFMPEG_PATH / system ffmpeg',
146
+ )
147
+ }
148
+ const sourceBuffer = await makeTestMp3(ffmpegPath)
149
+ expect(sourceBuffer.length).toBeGreaterThan(0)
150
+
151
+ const result = await convertCommonAudioBuffer(sourceBuffer, { format: 'wav', ffmpegPath })
152
+ expect(result.format).toBe('wav')
153
+ expect(result.contentType).toBe('audio/wav')
154
+ expect(result.size).toBeGreaterThan(0)
155
+ expect(result.buffer.subarray(0, 4).toString('ascii')).toBe('RIFF')
156
+ expect(result.buffer.subarray(8, 12).toString('ascii')).toBe('WAVE')
157
+ })
158
+
159
+ it('converts MP3 bytes to AIFF', async () => {
160
+ if (!ffmpegPath) {
161
+ throw new Error(
162
+ 'ffmpeg binary not found — install media-tasks devDependency ffmpeg-static, or set FFMPEG_PATH / system ffmpeg',
163
+ )
164
+ }
165
+ const sourceBuffer = await makeTestMp3(ffmpegPath)
166
+ const result = await convertCommonAudioBuffer(sourceBuffer, { format: 'aiff', ffmpegPath })
167
+ expect(result.format).toBe('aiff')
168
+ expect(result.contentType).toBe('audio/aiff')
169
+ expect(result.buffer.subarray(0, 4).toString('ascii')).toBe('FORM')
170
+ })
171
+
172
+ it('converts MP3 bytes to MP3 (re-encode)', async () => {
173
+ if (!ffmpegPath) {
174
+ throw new Error(
175
+ 'ffmpeg binary not found — install media-tasks devDependency ffmpeg-static, or set FFMPEG_PATH / system ffmpeg',
176
+ )
177
+ }
178
+ const sourceBuffer = await makeTestMp3(ffmpegPath)
179
+ const result = await convertCommonAudioBuffer(sourceBuffer, { format: 'mp3', ffmpegPath })
180
+ expect(result.format).toBe('mp3')
181
+ expect(result.contentType).toBe('audio/mpeg')
182
+ expect(result.size).toBeGreaterThan(0)
183
+ })
184
+
185
+ it('rejects an empty source buffer', async () => {
186
+ await expect(convertCommonAudioBuffer(Buffer.alloc(0))).rejects.toThrow(/source buffer/)
187
+ })
188
+ })