Guides

How to Parse an NDJSON Music API Stream in Node.js

Written by
Sonilo Team
Published
Node.js byte chunks become newline-delimited JSON events and a completed M4A soundtrack file.

Read the response body as bytes, decode UTF-8 incrementally, retain text until a newline arrives, parse one JSON event per line, append each validated audio chunk in order, and expose the M4A file only after a complete event.

Read the response body as bytes, decode UTF-8 incrementally, retain text until a newline arrives, parse one JSON event per line, append each validated audio chunk in order, and expose the M4A file only after a `complete` event. Never assume one network chunk equals one JSON object.

By Sonilo Team · Facts verified August 15, 2026

Disclosure: Sonilo publishes this guide and operates the API used as the concrete example. The `ndjson-stream-parser-v1` code was tested with controlled local streams; it was not a paid generation or a production-service benchmark.

Treat transport chunks and NDJSON lines as different things

The current Sonilo Video to Music API returns `200 application/x-ndjson` in stream mode. Its current full agent reference names four event types: `title`, `audio_chunk`, `complete`, and `error`. Each `audio_chunk.data` value is base64 audio, and streaming output is M4A.

NDJSON is newline-delimited JSON: one UTF-8 JSON text followed by LF, with CRLF also accepted. The NDJSON 1.0.0 specification says a parser should raise an error for invalid JSON and may ignore empty lines when that behavior is documented.

An HTTP body does not preserve those line boundaries. One read can stop halfway through a JSON object, halfway through a multibyte character, or after several complete events. A safe parser therefore needs two buffers with separate jobs:

  • The text decoder carries incomplete UTF-8 bytes across reads.
  • The line buffer carries incomplete JSON text until LF arrives.

Node.js implements the WHATWG decoder through `TextDecoder`. Its Web Streams API also supports `for await` consumption, so the parser can process data as it arrives instead of first buffering the entire HTTP response.

Define the acceptance contract before writing the file

The parser should separate “the server returned bytes” from “the application has a usable soundtrack.” These checks make that boundary explicit.

SignalAcceptReject
HTTP response`2xx` and `application/x-ndjson`Auth, balance, permission, validation, rate-limit, or server status
FramingUTF-8 JSON line ending in LF or CRLFInvalid UTF-8, malformed JSON, or an unterminated final line
Event orderZero or more title/audio events, then one `complete`Audio after completion, duplicate completion, or unknown event
Audio dataValid nonempty base64 for stream index 0Invalid base64, empty chunk, or unexpected stream index
Final fileAt least one audio chunk and a `complete` eventEOF, `error`, timeout, or zero audio

The current stream mode is the single-M4A path. The same Sonilo reference says non-M4A formats, speech preservation, ducking, and multiple variants require async mode. Use the separate bounded polling workflow when you choose that interface.

A tested Node.js parser and transactional writer

The following implementation uses only Node.js built-ins. Save these lines as `stream-video-to-music.mjs`. It writes to a unique `.part` file, requires successful completion and nonzero audio, then copies to the final path without overwriting an existing file.

  1. `import {COPYFILE_EXCL} from "node:constants";`
  2. `import {copyFile, open, rm} from "node:fs/promises";`
  3. `import {randomUUID} from "node:crypto";`
  4. `async function* parseNdjson(stream) {`
  5. ` const decoder = new TextDecoder("utf-8", {fatal: true});`
  6. ` let buffer = "";`
  7. ` let lineNumber = 0;`
  8. ` for await (const chunk of stream) {`
  9. ` if (!(chunk instanceof Uint8Array)) throw new Error("chunk_not_bytes");`
  10. ` buffer += decoder.decode(chunk, {stream: true});`
  11. ` let newlineAt;`
  12. ` while ((newlineAt = buffer.indexOf("\n")) !== -1) {`
  13. ` let line = buffer.slice(0, newlineAt);`
  14. ` buffer = buffer.slice(newlineAt + 1);`
  15. ` lineNumber += 1;`
  16. ` if (line.endsWith("\r")) line = line.slice(0, -1);`
  17. ` if (line.trim() === "") continue;`
  18. ` if (line.length > 1048576) throw new Error("line_too_long");`
  19. ` try { yield JSON.parse(line); }`
  20. ` catch { throw new Error("invalid_json_line_" + lineNumber); }`
  21. ` }`
  22. ` if (buffer.length > 1048576) throw new Error("line_too_long");`
  23. ` }`
  24. ` buffer += decoder.decode();`
  25. ` if (buffer.trim() !== "") throw new Error("incomplete_ndjson_line");`
  26. `}`
  27. `function decodeBase64(value) {`
  28. ` const pattern = new RegExp(`
  29. ` "^(?:[A-Za-z0-9+/]{4})*" +`
  30. ` "(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$",`
  31. ` );`
  32. ` if (typeof value !== "string" || value.length === 0 ||`
  33. ` value.length % 4 !== 0 || !pattern.test(value)) {`
  34. ` throw new Error("invalid_audio_base64");`
  35. ` }`
  36. ` return Buffer.from(value, "base64");`
  37. `}`
  38. `async function saveStream(response, outputPath) {`
  39. ` if (!response.ok) throw new Error("sonilo_http_" + response.status);`
  40. ` const type = (response.headers.get("content-type") ?? "")`
  41. ` .split(";", 1)[0].trim().toLowerCase();`
  42. ` if (type !== "application/x-ndjson") throw new Error("wrong_media_type");`
  43. ` if (!response.body) throw new Error("missing_response_body");`
  44. ` const part = outputPath + "." + randomUUID() + ".part";`
  45. ` let handle;`
  46. ` let complete = false;`
  47. ` let bytes = 0;`
  48. ` try {`
  49. ` handle = await open(part, "wx");`
  50. ` for await (const event of parseNdjson(response.body)) {`
  51. ` if (event.type === "title") continue;`
  52. ` if (event.type === "audio_chunk") {`
  53. ` if (complete) throw new Error("event_after_complete");`
  54. ` if ((event.stream_index ?? 0) !== 0) {`
  55. ` throw new Error("unexpected_stream_index");`
  56. ` }`
  57. ` const chunk = decodeBase64(event.data);`
  58. ` if (chunk.length === 0) throw new Error("empty_audio_chunk");`
  59. ` await handle.write(chunk);`
  60. ` bytes += chunk.length;`
  61. ` continue;`
  62. ` }`
  63. ` if (event.type === "complete") {`
  64. ` if (complete) throw new Error("duplicate_complete");`
  65. ` complete = true;`
  66. ` continue;`
  67. ` }`
  68. ` if (event.type === "error") throw new Error("stream_error");`
  69. ` throw new Error("unknown_stream_event");`
  70. ` }`
  71. ` if (!complete) throw new Error("stream_incomplete");`
  72. ` if (bytes === 0) throw new Error("stream_empty_audio");`
  73. ` await handle.sync();`
  74. ` await handle.close();`
  75. ` handle = undefined;`
  76. ` await copyFile(part, outputPath, COPYFILE_EXCL);`
  77. ` await rm(part);`
  78. ` return {outputPath, bytes};`
  79. ` } catch (error) {`
  80. ` await handle?.close().catch(() => {});`
  81. ` await rm(part, {force: true}).catch(() => {});`
  82. ` throw error;`
  83. ` }`
  84. `}`

This version skips blank lines, as permitted by NDJSON 1.0.0, and deliberately refuses a final JSON value without a newline. That strict ending turns a truncated transport into an error even when its remaining bytes happen to form valid JSON.

Make a complete streaming request from a server

Append this request code to the same file. It uses a remote `video_url` so the example stays dependency-free. Keep both the API key and any signed source URL on the server; the Next.js server-boundary guide covers that separation.

  1. `const apiKey = process.env.SONILO_API_KEY;`
  2. `const videoUrl = process.env.SONILO_VIDEO_URL;`
  3. `if (!apiKey || !videoUrl) throw new Error("missing_server_environment");`
  4. `const form = new FormData();`
  5. `form.set("video_url", videoUrl);`
  6. `form.set("mode", "stream");`
  7. `if (process.env.SONILO_PROMPT) {`
  8. ` form.set("prompt", process.env.SONILO_PROMPT);`
  9. `}`
  10. `const response = await fetch(`
  11. ` "https://api.sonilo.com/v1/video-to-music",`
  12. ` {`
  13. ` method: "POST",`
  14. ` headers: {`
  15. ` Authorization: "Bearer " + apiKey,`
  16. ` "User-Agent": "sonilo-ndjson-node-example/1.0",`
  17. ` },`
  18. ` body: form,`
  19. ` signal: AbortSignal.timeout(900000),`
  20. ` },`
  21. `);`
  22. `const result = await saveStream(response, "output.m4a");`
  23. `console.log(JSON.stringify(result, null, 2));`

Run it inside the trusted server environment:

  1. `SONILO_API_KEY=... SONILO_VIDEO_URL=... node stream-video-to-music.mjs`

`AbortSignal.timeout()` bounds the whole operation. The 15-minute value above is an application policy, not a provider service-level promise. Choose a value that fits your job budget, then treat an abort after submission as an uncertain outcome.

Keep HTTP failures separate from stream failures

The current Sonilo OpenAPI guide distinguishes authentication, balance, permissions, invalid input, rate limits, and server errors. Preserve that distinction before you hand the response to the NDJSON parser.

  • `401`: fix the server credential; do not retry unchanged.
  • `402`: resolve the balance or billing blocker; do not create a placeholder file.
  • `403`: verify the account and service permission.
  • `422`: correct the source or fields; do not resend the same invalid input.
  • `429`: honor `Retry-After` and use a bounded wait only after the server has explicitly rejected the request.
  • Network timeout or `5xx` after submission: do not blindly repeat the generation POST.

RFC 9110 warns against automatically retrying a non-idempotent request unless the client knows the retry is safe or knows the original request was not applied. Use the separate duplicate-generation guard for ambiguous submit outcomes.

Inside an accepted `200` stream, an `error` event, invalid line, invalid base64 value, unexpected event, missing completion marker, or clean EOF before completion should all remove the partial file. Do not treat “some bytes arrived” as success.

The ten-test contract

The `ndjson-stream-parser-v1` package ran on Node.js v22.23.2 with no external packages. Ten deterministic tests passed on August 15, 2026:

  1. Parse JSON split across arbitrary transport chunks.
  2. Preserve a UTF-8 character split between byte chunks.
  3. Accept LF and CRLF while skipping documented blank lines.
  4. Reject malformed JSON without copying its contents into an error message.
  5. Reject a final JSON value without the required newline.
  6. Reconstruct the exact audio bytes across multiple base64 events.
  7. Remove partial output after a stream `error` event.
  8. Remove partial output when EOF arrives without `complete`.
  9. Reject complete-without-audio and malformed base64.
  10. Check HTTP status, media type, and stream index before publication.

The fixtures were controlled `ReadableStream<Uint8Array>` values. They prove parser and local file-state behavior, not Sonilo production latency, network reliability, or generation quality.

When not to use streaming mode

Use async mode when a worker must survive process restarts, when a queue should resume from a stored task ID, or when the current endpoint requires async for your chosen output. The Sonilo reference currently requires async mode for WAV or MP3, speech preservation, ducking, and multiple variants.

Do not put the streaming request in browser code because that would expose the bearer credential and possibly the signed video URL. Do not use this single-output writer if your provider can interleave several output indexes; isolate one file and completion state per index instead.

Streaming also does not solve long-term storage. After you have a verified final file, move it into your normal durable-media workflow and record its checksum, size, content type, and tenant or usage scope. The temporary-output durability guide shows the corresponding acceptance boundary for remote result URLs.

Choose the response mode at the architecture boundary

Use streaming when one server process can stay attached to one M4A generation and the application benefits from receiving events immediately. Use async mode when resumability, multiple outputs, post-processing options, or independent workers matter more.

Start with the Sonilo Video-to-Music API guide for the broader request flow, then implement against the current endpoint documentation and OpenAPI 1.0.0. Keep the parser strict: a final file exists only after framing, events, audio, and completion all agree.