Skip to content

Commit

Permalink
Merge pull request #1355 from SimonKacir/master
Browse files Browse the repository at this point in the history
Audio normalization added
  • Loading branch information
nukeop authored Nov 7, 2022
2 parents 771fb8b + c7d8e41 commit 23bcaf5
Show file tree
Hide file tree
Showing 7 changed files with 194 additions and 65 deletions.
8 changes: 7 additions & 1 deletion packages/app/app/app.global.scss
Original file line number Diff line number Diff line change
Expand Up @@ -62,16 +62,22 @@ table {

::-webkit-scrollbar {
width: 0.5rem !important;
height: 0.5rem !important;
}

::-webkit-scrollbar-track {
background-color: $background !important;
border-radius: 0;
border-radius: 0.55rem !important;
}

::-webkit-scrollbar-thumb {
border-radius: 0.5rem !important;
background-color: $background2 !important;
transition: $short-duration;
}

::-webkit-scrollbar-corner {
background-color: transparent !important;
}

//Semantic UI section
Expand Down
119 changes: 119 additions & 0 deletions packages/app/app/components/Normalizer/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
/*
Adapted from https://github.com/est31/js-audio-normalizer
The MIT License (MIT)
Permission is hereby granted, free of charge, to any
person obtaining a copy of this software and associated
documentation files (the "Software"), to deal in the
Software without restriction, including without
limitation the rights to use, copy, modify, merge,
publish, distribute, sublicense, and/or sell copies of
the Software, and to permit persons to whom the Software
is furnished to do so, subject to the following
conditions:
The above copyright notice and this permission notice
shall be included in all copies or substantial portions
of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
*/

import { pluginFactory } from 'react-hifi';
import { Plugin } from 'react-hifi/dist/types/plugins/Plugin';

export interface NormalizerProps {
/** url of stream that is played */
url: string;
normalize: boolean;
}

export class Normalizer implements Plugin<NormalizerProps, GainNode> {
private audioContext: AudioContext;
constructor() {
this.createNode = this.createNode.bind(this);
}

shouldNotUpdate(prevProps: NormalizerProps, nextProps: NormalizerProps) {
return prevProps.url === nextProps.url;
}

normalizeTrack = (gainNode: GainNode, url: string, normalize) => {
if (!normalize) {
gainNode.gain.value = 1;
return;
}

// delay workaround with suspending audiocontext until fetch is finished
this.audioContext.suspend();

fetch(url)
.then((res) => res.arrayBuffer())
.then((arrayBuffer) => {
// use ArrayBuffer
this.audioContext.decodeAudioData(arrayBuffer).then((audioBuffer) => {
// use AudioBuffer

// perform calculations on audio data
const decodedBuffer = audioBuffer.getChannelData(0);
const sliceLen = Math.floor(audioBuffer.sampleRate * 0.05);
const averages = [];
let sum = 0.0;
for (let i = 0; i < decodedBuffer.length; i++) {
sum += decodedBuffer[i] ** 2;
if (i % sliceLen === 0) {
sum = Math.sqrt(sum / sliceLen);
averages.push(sum);
sum = 0;
}
}

// get average
sum = 0;
for (let i = 0; i< averages.length; i++){
sum += averages[i];
}
const a = sum/averages.length;

let gain = 1.0 / a;
// Perform some clamping
gain = Math.max(gain, 0.02);
gain = Math.min(gain, 100.0);

// ReplayGain uses pink noise for this one one but we just take
// some arbitrary value... we're no standard
// Important is only that we don't output on levels
// too different from other websites
gain = gain / 10.0;

// round the float value to 3 decimal places

gain = parseFloat(gain.toFixed(3));

gainNode.gain.value = gain;
this.audioContext.resume();
});
});
}

createNode = (audioContext: AudioContext, { url, normalize }: NormalizerProps) => {
this.audioContext = audioContext;
const gainNode = audioContext.createGain();
this.normalizeTrack(gainNode, url, normalize);
return gainNode;
}

updateNode = (node: GainNode, {url, normalize}: NormalizerProps) => {
this?.normalizeTrack(node, url, normalize);
}
}

export default pluginFactory<NormalizerProps, GainNode>(new Normalizer());
1 change: 1 addition & 0 deletions packages/app/app/components/Settings/styles.scss
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
line-height: 1rem;

.settings_item_name {
margin-bottom: 0.5em;
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,53 @@ exports[`Settings view container should render settings 1`] = `
</div>
</div>
</div>
<div
class="settings_section"
>
<div
class="header_container"
>
Audio
</div>
<hr />
<div
class="ui segment"
>
<div
class="settings_item boolean"
>
<span
class="settings_item_text"
>
<label
class="settings_item_name"
>
Normalize volume
</label>
<p
class="settings_item_description"
>
Automatically adjust volume of tracks so that they are played at the same level. This needs to fetch the whole track to work, so it may cause a delay between tracks.
</p>
</span>
<div
class="spacer"
/>
<div
class="ui fitted toggle checkbox"
>
<input
class="hidden"
readonly=""
tabindex="0"
type="radio"
value=""
/>
<label />
</div>
</div>
</div>
</div>
<div
class="settings_section"
>
Expand Down Expand Up @@ -1511,68 +1558,6 @@ exports[`Settings view container should render settings 1`] = `
</div>
</div>
</div>
<div
class="settings_section"
>
<div
class="header_container"
>
Social
</div>
<hr />
<div
class="ui segment"
>
<div
class="settings_item string"
>
<span
class="settings_item_text"
>
<label
class="settings_item_name"
>
Nuclear identity service URL
</label>
</span>
<div
class="spacer"
/>
<div
class="ui fluid input"
>
<input
type="text"
value=""
/>
</div>
</div>
<div
class="settings_item string"
>
<span
class="settings_item_text"
>
<label
class="settings_item_name"
>
Nuclear playlists service URL
</label>
</span>
<div
class="spacer"
/>
<div
class="ui fluid input"
>
<input
type="text"
value=""
/>
</div>
</div>
</div>
</div>
</div>
</div>
</DocumentFragment>
Expand Down
5 changes: 5 additions & 0 deletions packages/app/app/containers/SoundContainer/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import * as LyricsActions from '../../actions/lyrics';
import { filterFrequencies } from '../../components/Equalizer/chart';
import * as Autoradio from './autoradio';
import VisualizerContainer from '../../containers/VisualizerContainer';
import Normalizer from '../../components/Normalizer';
import globals from '../../globals';
import HlsPlayer from '../../components/HLSPlayer';
import { ipcRenderer } from 'electron';
Expand Down Expand Up @@ -206,6 +207,10 @@ class SoundContainer extends React.Component {
position={player.seek}
onError={this.handleError}
>
<Normalizer
url={currentStream.stream}
normalize={this.props.settings.normalize}
/>
<Volume value={player.muted ? 0 : player.volume} />
<Equalizer
data={filterFrequencies.reduce((acc, freq, idx) => ({
Expand Down
14 changes: 12 additions & 2 deletions packages/core/src/settings/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,14 @@ export const settingsConfig: Array<Setting> = [
default: 50,
hide: true
},
{
name: 'normalize',
category: 'audio',
description: 'normalize-description',
type: SettingType.BOOLEAN,
prettyName: 'normalize',
default: false
},
{
name: 'loopAfterQueueEnd',
category: 'playback',
Expand Down Expand Up @@ -330,13 +338,15 @@ export const settingsConfig: Array<Setting> = [
prettyName: 'nuclear-identity-service-url',
category: 'social',
type: SettingType.STRING,
default: 'http://localhost:3000'
default: 'http://localhost:3000',
hide: true
},
{
name: 'nuclearPlaylistsServiceUrl',
prettyName: 'nuclear-playlists-service-url',
category: 'social',
type: SettingType.STRING,
default: 'http://localhost:3010'
default: 'http://localhost:3010',
hide: true
}
];
3 changes: 3 additions & 0 deletions packages/i18n/src/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,7 @@
"settings": {
"api-port": "Port used by the api",
"api-url": "",
"audio": "Audio",
"autoradio": "Autoradio",
"autoradio-craziness": "Autoradio craziness",
"autoradio-craziness-description": "Autoradio will select songs that are less similar to the ones already in the queue the crazier it is",
Expand Down Expand Up @@ -291,6 +292,8 @@
"mastodon-post-format-description": "Nuclear will post a status on Mastodon after each track completes playing. The above string will be the template for each post, with {{artist}} and {{title}} replaced with the artist and title of the track.",
"mastodon-post-format-label": "Post format",
"mastodon-title": "",
"normalize": "Normalize volume",
"normalize-description": "Automatically adjust volume of tracks so that they are played at the same level. This needs to fetch the whole track to work, so it may cause a delay between tracks.",
"nuclear-identity-service-url": "Nuclear identity service URL",
"nuclear-playlists-service-url": "Nuclear playlists service URL",
"mini-player": "Use mini player style",
Expand Down

0 comments on commit 23bcaf5

Please sign in to comment.