Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -915,7 +915,9 @@ def download_logs_zip() -> StreamingResponse:
# Content-Security-Policy. Defense-in-depth so an injected string in the webview
# can't run script (and, in the desktop app, reach the exposed Tauri IPC) — #171.
# script-src has no 'unsafe-inline'/'eval': all JS is same-origin modules and the
# inline scripts/onclick were moved out. 'unsafe-inline' is allowed for *styles*
# inline scripts/onclick were moved out. 'wasm-unsafe-eval' lets WebAssembly
# compile, which the Signalsmith tempo stage in the audio worklet needs (#729);
# it does not re-enable JS eval, new Function or string timers. 'unsafe-inline' is allowed for *styles*
# only (the UI sets many style attributes). Allowances:
# connect-src -> same-origin API/SSE, the GitHub update check, Tauri IPC,
# Wikidata/Wikipedia for the artist box the now-playing card
Expand All @@ -926,7 +928,7 @@ def download_logs_zip() -> StreamingResponse:
# style/font -> the Google Fonts <link>
_CSP = (
"default-src 'self'; "
"script-src 'self'; "
"script-src 'self' 'wasm-unsafe-eval'; "
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; "
"font-src 'self' https://fonts.gstatic.com data:; "
"img-src 'self' data: blob: https:; "
Expand Down
2 changes: 1 addition & 1 deletion desktop/src-tauri/tauri.conf.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
}
],
"security": {
"csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com data:; img-src 'self' data: blob:; connect-src 'self' ipc: http://ipc.localhost; object-src 'none'; base-uri 'self'; frame-ancestors 'none'"
"csp": "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com data:; img-src 'self' data: blob:; connect-src 'self' ipc: http://ipc.localhost; object-src 'none'; base-uri 'self'; frame-ancestors 'none'"
}
},
"bundle": {
Expand Down
30 changes: 17 additions & 13 deletions static/js/audioEngine.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
} from "./pitchBus.js";
import { createPlaybackContext } from "./audioContext.js";
import { createTickLoop } from "./tickLoop.js";
import { loadStretchWorklet, pipelineLatencySeconds as stretchLatency } from "./tempoStage.js";

export function createAudioEngine(stems, { onTime, onEnded, context } = {}) {
// Mobile/iOS only starts audio from a context resumed inside a user gesture.
Expand Down Expand Up @@ -53,15 +54,22 @@ export function createAudioEngine(stems, { onTime, onEnded, context } = {}) {
// Reported by the processor, because deriving it here would mean keeping a
// copy of its buffering constants in sync by hand.
let _workletLatencyFrames = 0;
// Which tempo stage the processor is running, and its latency (#729).
let _tempoStage = null;
const _workletReady = (ctx.audioWorklet
? ctx.audioWorklet.addModule('/vendor/soundtouch-processor.js').then(() => {
? loadStretchWorklet(ctx.audioWorklet).then(() => {
stNode = new AudioWorkletNode(ctx, 'soundtouch-processor', {
numberOfInputs: INPUT_COUNT,
numberOfOutputs: 1,
outputChannelCount: [2],
});
stNode.port.onmessage = (event) => {
if (event?.data?.type === 'latency') _workletLatencyFrames = event.data.frames || 0;
const data = event?.data;
if (data?.type === 'latency') _workletLatencyFrames = data.frames || 0;
else if (data?.type === 'tempoStage') _tempoStage = data;
else if (data?.type === 'tempoStageFailed') {
console.warn('[tempoStage] Signalsmith core failed to start, keeping WSOLA:', data.message);
}
};
// The worklet loads asynchronously, so anything set before it arrived
// would otherwise be dropped. Re-apply the current value now.
Expand Down Expand Up @@ -187,12 +195,6 @@ export function createAudioEngine(stems, { onTime, onEnded, context } = {}) {
// (startCtxTime > ctx.currentTime), which would otherwise make this go
// negative -- the playhead must sit still at the start until the audio enters.
// A no-op for a normal start, where startCtxTime == the moment play() ran.
const wsolaLatencySeconds = () => {
const needed = Math.round(0.012 * ctx.sampleRate)
+ Math.round(0.028 * ctx.sampleRate)
+ Math.round(0.082 * ctx.sampleRate);
return Math.floor(needed / 128) * 128 / ctx.sampleRate;
};
const anyLaneTransposed = () => {
for (const t of tracks.values()) {
if (t.visualOnly) continue;
Expand All @@ -202,11 +204,13 @@ export function createAudioEngine(stems, { onTime, onEnded, context } = {}) {
};
const pipelineLatencySeconds = () => {
if (!stNode) return 0;
// The pitch buses only buffer once something is actually transposed. Until
// then the worklet hands its input straight back, with no delay to correct.
const pitchLatency = anyLaneTransposed() ? _workletLatencyFrames / ctx.sampleRate : 0;
const tempoStages = Math.abs(_playbackRate - 1) >= 1e-3 ? 1 : 0;
return pitchLatency + tempoStages * wsolaLatencySeconds();
return stretchLatency({
workletFrames: _workletLatencyFrames,
stage: _tempoStage,
rate: _playbackRate,
transposed: anyLaneTransposed(),
sampleRate: ctx.sampleRate,
});
};
const now = () => playing
? Math.max(
Expand Down
38 changes: 24 additions & 14 deletions static/js/chunkedAudioEngine.js
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,7 @@ import {
} from "./pitchBus.js";
import { createPlaybackContext } from "./audioContext.js";
import { createTickLoop } from "./tickLoop.js";
import { loadStretchWorklet, pipelineLatencySeconds as stretchLatency } from "./tempoStage.js";

export function createChunkedAudioEngine(stems, { onTime, onEnded, context } = {}) {
const AC = window.AudioContext || window.webkitAudioContext;
Expand Down Expand Up @@ -226,15 +227,22 @@ export function createChunkedAudioEngine(stems, { onTime, onEnded, context } = {
// Reported by the processor, because deriving it here would mean keeping a
// copy of its buffering constants in sync by hand.
let _workletLatencyFrames = 0;
// Which tempo stage the processor is running, and its latency (#729).
let _tempoStage = null;
const _workletReady = (ctx.audioWorklet
? ctx.audioWorklet.addModule('/vendor/soundtouch-processor.js').then(() => {
? loadStretchWorklet(ctx.audioWorklet).then(() => {
stNode = new AudioWorkletNode(ctx, 'soundtouch-processor', {
numberOfInputs: INPUT_COUNT,
numberOfOutputs: 1,
outputChannelCount: [2],
});
stNode.port.onmessage = (event) => {
if (event?.data?.type === 'latency') _workletLatencyFrames = event.data.frames || 0;
const data = event?.data;
if (data?.type === 'latency') _workletLatencyFrames = data.frames || 0;
else if (data?.type === 'tempoStage') _tempoStage = data;
else if (data?.type === 'tempoStageFailed') {
console.warn('[tempoStage] Signalsmith core failed to start, keeping WSOLA:', data.message);
}
};
// The worklet loads asynchronously, so anything set before it arrived
// would otherwise be dropped. Re-apply the current value now.
Expand Down Expand Up @@ -335,12 +343,6 @@ export function createChunkedAudioEngine(stems, { onTime, onEnded, context } = {
// schedule chunk 0 without an async await after ready() completes.
const _cache = new Map();

const _wsolaLatencySeconds = () => {
const needed = Math.round(0.012 * ctx.sampleRate)
+ Math.round(0.028 * ctx.sampleRate)
+ Math.round(0.082 * ctx.sampleRate);
return Math.floor(needed / 128) * 128 / ctx.sampleRate;
};
const _anyLaneTransposed = () => {
for (const stem of stemMap.values()) {
if (effectivePitch(stem.name, stem.pitch, stem.pitchable) !== 0) return true;
Expand All @@ -349,11 +351,13 @@ export function createChunkedAudioEngine(stems, { onTime, onEnded, context } = {
};
const _pipelineLatencySeconds = () => {
if (!stNode) return 0;
// The pitch buses only buffer once something is actually transposed. Until
// then the worklet hands its input straight back, with no delay to correct.
const pitchLatency = _anyLaneTransposed() ? _workletLatencyFrames / ctx.sampleRate : 0;
const tempoStages = Math.abs(_playbackRate - 1) >= 1e-3 ? 1 : 0;
return pitchLatency + tempoStages * _wsolaLatencySeconds();
return stretchLatency({
workletFrames: _workletLatencyFrames,
stage: _tempoStage,
rate: _playbackRate,
transposed: _anyLaneTransposed(),
sampleRate: ctx.sampleRate,
});
};

// Ducking either side of a bus change. Both buses are delayed by the same
Expand Down Expand Up @@ -603,7 +607,13 @@ export function createChunkedAudioEngine(stems, { onTime, onEnded, context } = {
// loop.start when the playhead crosses it (bounded by one rAF frame).
const limit = (loop.enabled && loop.end > loop.start) ? loop.end : _duration;
if (_scheduledTo >= limit) return;
if (_scheduledTo - _getCurrentTime() < LOOKAHEAD_SEC) {
// Measured against the sources, not the output playhead. With SoundTouch
// stretching, sources play at 1x and the worklet buffers the surplus, so
// below 1x the output playhead falls behind what the sources have used up.
// Gating on it overstated the margin by (1 - rate) seconds every second
// until the sources ran dry and the next chunk landed late and skipped
// (#722). In the tape-effect fallback the two clocks agree.
if (_scheduledTo - ctxTimeToSourceTime(ctx.currentTime) < LOOKAHEAD_SEC) {
_filling = true;
_scheduleNext().finally(() => { _filling = false; });
}
Expand Down
58 changes: 58 additions & 0 deletions static/js/tempoStage.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
// Loading the worklet, and how late its output runs, for both audio engines.
//
// Both engines drive the same processor and used to carry their own copy of
// its latency. Signalsmith Stretch (#729) made that a formula that depends on
// the tempo and on which stage loaded, and a third copy of it would drift.

const CORE_URL = "/vendor/signalsmith-stretch.js";
const PROCESSOR_URL = "/vendor/soundtouch-processor.js";

/**
* Add the Signalsmith core, then the processor.
*
* The core is optional. A browser without WebAssembly in worklets, or a CSP
* without 'wasm-unsafe-eval', fails here, and the processor then keeps WSOLA
* as its tempo stage. Only the processor failing fails the whole thing.
*/
export async function loadStretchWorklet(audioWorklet) {
try {
await audioWorklet.addModule(CORE_URL);
} catch (err) {
console.warn("[tempoStage] Signalsmith core did not load, keeping WSOLA:", err);
}
await audioWorklet.addModule(PROCESSOR_URL);
}

// WSOLA's tempo stage, as the engines have always counted it.
function wsolaLatencySeconds(sampleRate) {
const needed = Math.round(0.012 * sampleRate)
+ Math.round(0.028 * sampleRate)
+ Math.round(0.082 * sampleRate);
return Math.floor(needed / 128) * 128 / sampleRate;
}

/**
* Seconds between a source sample entering the worklet and it being heard.
*
* @param {object} p
* @param {number} p.workletFrames the processor's `latency` message: the
* priming every bus goes through once the worklet is doing anything
* @param {object|null} p.stage the processor's latest `tempoStage` message
* @param {number} p.rate playback rate
* @param {boolean} p.transposed whether any lane is off its own key
* @param {number} p.sampleRate
*/
export function pipelineLatencySeconds({ workletFrames, stage, rate, transposed, sampleRate }) {
const tempoActive = Math.abs(rate - 1) >= 1e-3;
// The pitch buses only buffer once something is actually transposed. Until
// then the worklet hands its input straight back, with no delay to correct.
const pitchSeconds = transposed ? workletFrames / sampleRate : 0;
if (!tempoActive) return pitchSeconds;
if (stage?.stage === "signalsmith") {
// The priming and the core's input side are counted in input samples, so
// they stretch with the tempo; its output side does not. Measured against
// the processor end to end, this lands within a millisecond.
return ((workletFrames + stage.inputFrames) / rate + stage.outputFrames) / sampleRate;
}
return pitchSeconds + wsolaLatencySeconds(sampleRate);
}
53 changes: 53 additions & 0 deletions static/vendor/signalsmith-stretch.js

Large diffs are not rendered by default.

Loading
Loading