From 2c9439cf8cbbda399d01f72e14ae54272507f424 Mon Sep 17 00:00:00 2001 From: dima_dencep Date: Sat, 5 Sep 2026 14:24:05 +0700 Subject: [PATCH 1/5] Document .opus emote sound and add a converter for it Emote sound moved from Note Block Studio to Opus, so creatingmusic.md now describes putting an .opus next to the emote, the LOOPSTART tag that makes it repeat, the limits the mod enforces, and why the file should not be normalised by hand. Note Block Studio stays at the end: current versions no longer play .nbs, but it is still passed through to older clients, so shipping both is worth explaining. Writing that file by hand means knowing that Emotecraft wants mono at 48 kHz and that the result has to fit in a network packet, which is a lot to ask of someone who just wants a song under their emote. /opus-converter does it in the browser instead: drop in any audio file, pick quality, trim and looping, and download something the mod will accept. It estimates the size before encoding and refuses to produce a file that is over the limit. ffmpeg.wasm does the encoding. Its wrapper comes from npm but is imported lazily so the static build never touches it, and the 31 MB core is fetched from a CDN rather than committed here. Encoding is constrained VBR: plain VBR overshot a 32 kbps target by 30%, which matters when the ceiling is 1 MB. Co-Authored-By: Claude Opus 5 (1M context) --- emotecraft/creatingemotes/creatingmusic.md | 82 +++- package-lock.json | 32 ++ package.json | 2 + src/pages/opus-converter.module.css | 87 +++++ src/pages/opus-converter.tsx | 421 +++++++++++++++++++++ 5 files changed, 615 insertions(+), 9 deletions(-) create mode 100644 src/pages/opus-converter.module.css create mode 100644 src/pages/opus-converter.tsx diff --git a/emotecraft/creatingemotes/creatingmusic.md b/emotecraft/creatingemotes/creatingmusic.md index f1a0857..26b95b4 100644 --- a/emotecraft/creatingemotes/creatingmusic.md +++ b/emotecraft/creatingemotes/creatingmusic.md @@ -1,19 +1,83 @@ --- sidebar_position: 3 -description: "How to create music for emotes using Note Block Studio." +description: "How to give an emote a sound, and how to prepare the .opus file it needs." --- # Creating Music for Emotes -You can make music for your emotes using [Note Block Studio](https://noteblock.studio/)! -Once that is done run the program, you should be greeted with a blank song. Here you can create the song -When you are done the song click save and save it to the emotes folder as \{nameOfEmoteFile\}.nbs. +An emote can carry its own sound. Put an `.opus` file in the emotes folder next to the emote and give it the +same name, and Emotecraft plays it whenever the emote plays — for you and for everyone who sees you. + +```text +emotes/ + wave.json + wave.opus +``` + +The sound travels with the emote, so other players hear it without having to download anything themselves. + +## Making the file + +The easiest way is the **[Emote Sound Converter](/opus-converter)**. Drop in any audio file, choose how it +should sound, and download the result. It runs entirely in your browser, nothing is uploaded anywhere, and it +will not let you make a file the mod would reject. + +If you would rather do it yourself, [ffmpeg](https://ffmpeg.org/) does the same job: + +```bash +ffmpeg -i song.mp3 -c:a libopus -b:a 32k -vbr constrained -ac 1 -ar 48000 wave.opus +``` + +The sound has to be **mono**. Everything else below is a limit rather than a requirement. + +## Looping + +By default the sound plays once, even if the emote itself repeats forever. To make it loop, add a +`LOOPSTART` tag saying which sample it should jump back to. There are 48000 samples in a second, so +`LOOPSTART=96000` restarts from the two second mark: + +```bash +ffmpeg -i song.mp3 -c:a libopus -b:a 32k -vbr constrained -ac 1 -ar 48000 \ + -metadata LOOPSTART=96000 wave.opus +``` + +Use `LOOPSTART=0` to simply start over from the beginning. A later value is for tracks with an intro that +should only be heard once. The converter has a checkbox for this and works the number out for you. + +The sound and the animation loop independently, each on its own length, so they drift apart over time unless +they happen to line up. + +## Limits + +| | | +|---|---| +| Channels | mono only | +| Size | 1 MB, shared with the emote itself | +| Bitrate | 96 kbps, and low enough to fit the size limit | +| Length | 10 minutes | + +The size limit is the one you will actually run into: it is the largest packet a Minecraft server will pass +along, and the animation has to fit in it too. At 32 kbps that is roughly four and a half minutes of music, +which is plenty for most emotes. If the sound does not fit, the emote still plays — just silently for +everyone else. + +## Volume + +Emotecraft measures how loud each sound is and evens them out, so that a quiet emote and a loud one sit at +the same level. Do not normalise or amplify the file yourself; leave it as it is. Players who prefer the +original loudness can turn this off with **Normalize the volume of emote sounds** in the options. + +If the file carries an `R128_TRACK_GAIN` tag, that is used instead of measuring, which saves a little work +when the emote loads. + +## Note Block Studio (legacy) + +Older versions used [Note Block Studio](https://noteblock.studio/) songs saved as `\{nameOfEmoteFile\}.nbs`. +Current versions no longer play them, but they are still passed along: keep the `.nbs` next to the emote and +players on an older version of the mod will hear it, while everyone else hears the `.opus`. Shipping both is +safe. # Useful links +- [Emote Sound Converter](/opus-converter) - Turn any audio file into an emote sound, in your browser. - [Note Block World](https://noteblock.world/) - The largest public community centered around Minecraft note blocks. - [Note Block Tool](https://github.com/RaphiMC/NoteBlockTool/releases/latest) - Tool for importing, exporting, batch manipulating and playing Minecraft note block songs. - -# Limitations -- In 1.21.5+ nearly every feature is supported -- In versions lower you have only default instruments and 2.5 octaves -- Only nbs is supported by the main mod; you can use [EmoteTweaks](https://modrinth.com/mod/emote-tweaks) to play `.wav` files, but it's unstable. diff --git a/package-lock.json b/package-lock.json index 4c3af2c..27f5732 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,8 @@ "dependencies": { "@docusaurus/core": "^3.9.2", "@docusaurus/preset-classic": "^3.9.2", + "@ffmpeg/ffmpeg": "^0.12.15", + "@ffmpeg/util": "^0.12.2", "@mdx-js/react": "^3.1.1", "clsx": "^2.1.1", "prism-react-renderer": "^2.4.1", @@ -4135,6 +4137,36 @@ "node": ">=20.0" } }, + "node_modules/@ffmpeg/ffmpeg": { + "version": "0.12.15", + "resolved": "https://registry.npmjs.org/@ffmpeg/ffmpeg/-/ffmpeg-0.12.15.tgz", + "integrity": "sha512-1C8Obr4GsN3xw+/1Ww6PFM84wSQAGsdoTuTWPOj2OizsRDLT4CXTaVjPhkw6ARyDus1B9X/L2LiXHqYYsGnRFw==", + "license": "MIT", + "dependencies": { + "@ffmpeg/types": "^0.12.4" + }, + "engines": { + "node": ">=18.x" + } + }, + "node_modules/@ffmpeg/types": { + "version": "0.12.4", + "resolved": "https://registry.npmjs.org/@ffmpeg/types/-/types-0.12.4.tgz", + "integrity": "sha512-k9vJQNBGTxE5AhYDtOYR5rO5fKsspbg51gbcwtbkw2lCdoIILzklulcjJfIDwrtn7XhDeF2M+THwJ2FGrLeV6A==", + "license": "MIT", + "engines": { + "node": ">=16.x" + } + }, + "node_modules/@ffmpeg/util": { + "version": "0.12.2", + "resolved": "https://registry.npmjs.org/@ffmpeg/util/-/util-0.12.2.tgz", + "integrity": "sha512-ouyoW+4JB7WxjeZ2y6KpRvB+dLp7Cp4ro8z0HIVpZVCM7AwFlHa0c4R8Y/a4M3wMqATpYKhC7lSFHQ0T11MEDw==", + "license": "MIT", + "engines": { + "node": ">=18.x" + } + }, "node_modules/@hapi/hoek": { "version": "9.3.0", "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz", diff --git a/package.json b/package.json index 10632b9..4e64599 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,8 @@ "dependencies": { "@docusaurus/core": "^3.9.2", "@docusaurus/preset-classic": "^3.9.2", + "@ffmpeg/ffmpeg": "^0.12.15", + "@ffmpeg/util": "^0.12.2", "@mdx-js/react": "^3.1.1", "clsx": "^2.1.1", "prism-react-renderer": "^2.4.1", diff --git a/src/pages/opus-converter.module.css b/src/pages/opus-converter.module.css new file mode 100644 index 0000000..96123be --- /dev/null +++ b/src/pages/opus-converter.module.css @@ -0,0 +1,87 @@ +.drop { + border: 2px dashed var(--ifm-color-emphasis-300); + border-radius: var(--ifm-global-radius); + padding: 2.5rem 1rem; + text-align: center; + cursor: pointer; + transition: border-color 0.15s ease, background-color 0.15s ease; +} + +.drop:hover, +.dropOver { + border-color: var(--ifm-color-primary); + background-color: var(--ifm-color-emphasis-100); +} + +.dropTitle { + display: block; + font-weight: 600; + margin-bottom: 0.25rem; +} + +.hidden { + display: none; +} + +.field { + margin-bottom: 1rem; +} + +.field label { + display: block; + font-weight: 600; + margin-bottom: 0.25rem; +} + +.field select, +.field input[type='text'] { + width: 100%; + padding: 0.5rem 0.6rem; + border: 1px solid var(--ifm-color-emphasis-300); + border-radius: var(--ifm-global-radius); + background-color: var(--ifm-background-color); + color: var(--ifm-font-color-base); + font: inherit; + font-size: 0.95rem; +} + +.hint { + color: var(--ifm-color-emphasis-700); + font-size: 0.875rem; + margin: 0.35rem 0 0; +} + +.field label.toggle { + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 0; + cursor: pointer; +} + +.toggle input { + width: 1.05rem; + height: 1.05rem; + margin: 0; + accent-color: var(--ifm-color-primary); +} + +.summary { + width: 100%; + margin-bottom: 1rem; +} + +.summary td { + border: 0; + padding: 0.25rem 0; +} + +.summary td:last-child { + text-align: right; + font-variant-numeric: tabular-nums; +} + +.player { + width: 100%; + margin-bottom: 1rem; +} diff --git a/src/pages/opus-converter.tsx b/src/pages/opus-converter.tsx new file mode 100644 index 0000000..87f205f --- /dev/null +++ b/src/pages/opus-converter.tsx @@ -0,0 +1,421 @@ +import React, {useEffect, useRef, useState} from 'react'; +import Layout from '@theme/Layout'; +import CodeBlock from '@theme/CodeBlock'; +import styles from './opus-converter.module.css'; + +// Emotecraft refuses anything past these, so the page never lets you produce one +const MAX_BYTES = 1048576; +const MAX_SECONDS = 600; +const MAX_KBPS = 96; +const SAMPLE_RATE = 48000; + +// The wasm build is 31 MB, so it is fetched from a CDN rather than committed here +const CORE = 'https://cdn.jsdelivr.net/npm/@ffmpeg/core@0.12.10/dist/umd'; + +const QUALITIES = [ + {kbps: 16, label: '16 kbps — speech, tiny file'}, + {kbps: 24, label: '24 kbps — small'}, + {kbps: 32, label: '32 kbps — recommended for music'}, + {kbps: 48, label: '48 kbps — better music'}, + {kbps: 64, label: '64 kbps — good music'}, + {kbps: MAX_KBPS, label: '96 kbps — the most Emotecraft allows'}, +]; + +type Plan = { + error?: string; + from: number; + length: number; + loopAt: number; + bitrate: number; + bytes: number; +}; + +type Result = {url: string; name: string; size: number; length: number; loopAt: number | null}; + +/** Accepts 90, 1:30 or 1:02:03, all meaning a number of seconds. */ +function parseTime(value: string, fallback: number): number { + const text = value.trim(); + if (!text) return fallback; + + const parts = text.split(':').map(Number); + if (parts.some((part) => !isFinite(part) || part < 0)) return NaN; + if (parts.length === 1) return parts[0]; + if (parts.length === 2) return parts[0] * 60 + parts[1]; + if (parts.length === 3) return parts[0] * 3600 + parts[1] * 60 + parts[2]; + return NaN; +} + +function showTime(seconds: number): string { + const whole = Math.floor(seconds); + return `${Math.floor(whole / 60)}:${String(whole % 60).padStart(2, '0')}`; +} + +function showSize(bytes: number): string { + return bytes < MAX_BYTES ? `${Math.round(bytes / 1024)} KB` : `${(bytes / MAX_BYTES).toFixed(2)} MB`; +} + +export default function OpusConverter(): React.ReactElement { + const [file, setFile] = useState(null); + const [duration, setDuration] = useState(0); + const [reading, setReading] = useState(false); + const [unreadable, setUnreadable] = useState(false); + + const [kbps, setKbps] = useState(32); + const [start, setStart] = useState(''); + const [end, setEnd] = useState(''); + const [loop, setLoop] = useState(false); + const [loopAt, setLoopAt] = useState(''); + + const [busy, setBusy] = useState(false); + const [status, setStatus] = useState(''); + const [failure, setFailure] = useState(''); + const [result, setResult] = useState(null); + const [over, setOver] = useState(false); + + const input = useRef(null); + const ffmpeg = useRef(null); + + useEffect(() => () => { + if (result) URL.revokeObjectURL(result.url); + }, [result]); + + async function choose(chosen: File | undefined) { + if (!chosen) return; + + setReading(true); + setUnreadable(false); + setResult(null); + setFailure(''); + + // Decoding is how the browser tells us the real length; an