diff --git a/emotecraft/creatingemotes/creatingmusic.md b/emotecraft/creatingemotes/creatingmusic.md index f1a0857..7528897 100644 --- a/emotecraft/creatingemotes/creatingmusic.md +++ b/emotecraft/creatingemotes/creatingmusic.md @@ -1,19 +1,129 @@ --- 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. + +## What .opus is + +Nothing special, and nothing invented for this mod. Opus is an ordinary audio format, the same one Discord and +most voice chats use, and `.opus` is simply an audio file holding it. Emotecraft asks for it because it stays +small: a few hundred kilobytes for a whole song, which is what makes sending the sound along with the emote +possible at all. + +Playing one back depends on your system: + +| | | +|---|---| +| Windows 10 and 11 | opens as is | +| Linux, Android | opens as is | +| macOS | QuickTime and Music cannot, [VLC](https://www.videolan.org/vlc/) can | +| Anywhere | drag the file into a Chrome or Firefox window | + +That browser trick is the quickest way to check a file without installing anything, and the converter below +lets you listen to the result before you download it at all. + +## 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 -af "pan=mono|c0=0.5*c0+0.5*c1,alimiter=level=disabled:limit=0.9" \ + -c:a libopus -b:a 32k -vbr constrained -ar 48000 wave.opus +``` + +Do not reach for `-ac 1` to make it mono. It adds the two channels together without halving them, which +is 6 dB of gain the track never asked for, and everything above full scale comes back as crackling. The +`pan` filter mixes them properly, and the limiter catches the overshoot the encoder itself adds at low +bitrates. `level=disabled` matters: without it the limiter puts the level straight back. + +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 -af "pan=mono|c0=0.5*c0+0.5*c1,alimiter=level=disabled:limit=0.9" \ + -c:a libopus -b:a 32k -vbr constrained -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. + +Both ends are honoured to the sample. An encoder pads its last frame out to a whole 20 ms, and that padding +is dropped rather than played, so a track that was seamless stays seamless. + +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. + +They are still passed along, though, so an emote can carry both files at once: + +```text +emotes/ + wave.json + wave.opus + wave.nbs +``` + +Everyone hears something. Players on a current version get the `.opus`; players on an older one get the +`.nbs`, because the mod keeps sending it to anyone who cannot read Opus. A server passes on whichever one +each player is able to play, so a single pack works across versions. + +If you are publishing an emote that already has a `.nbs`, keeping it costs a few kilobytes and loses nothing. +Only the `.opus` is sent to modern clients; the `.nbs` travels alongside it when an emote is stored, and on +its own when the other side is old. + +Emotes from [EmotecraftLibrary (RedlanceEmotes)](https://emotes.redlance.org/) need none of this: the library +generates the `.opus` for every emote that has a `.nbs`, so downloads already carry both. # 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..b360b9c --- /dev/null +++ b/src/pages/opus-converter.tsx @@ -0,0 +1,426 @@ +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'; + +// `-ac 1` sums the channels without halving them, which is +6 dB and clips on playback. The limiter +// then catches what the encoder itself overshoots; without level=disabled it would undo its own work. +const DOWNMIX = 'pan=mono|c0=0.5*c0+0.5*c1,alimiter=level=disabled:limit=0.9'; + +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