Add new version 0.3.8 and archive old version 0.3.6 of Time4Midi
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -0,0 +1,22 @@
|
||||
directories:
|
||||
output: dist-033-diagnostics3
|
||||
buildResources: build
|
||||
appId: com.jhartworks.time4midi
|
||||
productName: Time4Midi
|
||||
files:
|
||||
- filter:
|
||||
- main.js
|
||||
- preload.js
|
||||
- src/**/*
|
||||
- JHArtworks2023_wh.png
|
||||
- time4midi.png
|
||||
- package.json
|
||||
npmRebuild: false
|
||||
win:
|
||||
icon: time4midi.ico
|
||||
signAndEditExecutable: false
|
||||
target:
|
||||
- target: portable
|
||||
arch:
|
||||
- x64
|
||||
electronVersion: 42.4.0
|
||||
@@ -0,0 +1,21 @@
|
||||
Copyright (c) Electron contributors
|
||||
Copyright (c) 2013-2020 GitHub Inc.
|
||||
|
||||
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.
|
||||
File diff suppressed because it is too large
Load Diff
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1 @@
|
||||
{"file_format_version": "1.0.0", "ICD": {"library_path": ".\\vk_swiftshader.dll", "api_version": "1.0.5"}}
|
||||
Binary file not shown.
Vendored
BIN
Binary file not shown.
+465
-48
@@ -12,6 +12,10 @@
|
||||
const EXTERNAL_IDLE_MS = 1500;
|
||||
const AUDIO_WAVEFORM_SAMPLES = 720;
|
||||
const AUDIO_SYNC_TOLERANCE = 0.08;
|
||||
const AUDIO_SYNC_TOLERANCE_PLAYING = 0.22;
|
||||
const AUDIO_SYNC_THROTTLE_MS = 220;
|
||||
const AUDIO_VOLUME_THROTTLE_MS = 33;
|
||||
const AUDIO_VOLUME_EPSILON = 0.002;
|
||||
const AUDIO_SNAP_GRID_OPTIONS = ["0.25", "0.5", "1", "2", "4", "8"];
|
||||
const AUDIO_SNAP_LINE_WIDTH_OPTIONS = ["1", "2", "3", "4"];
|
||||
const KNX_DPT_OPTIONS = [
|
||||
@@ -338,6 +342,11 @@
|
||||
audioOutputDevices: [],
|
||||
audioAnalysisContext: null,
|
||||
audioTracks: new Map(),
|
||||
audioSync: {
|
||||
running: false,
|
||||
pending: false,
|
||||
pendingForceSeek: false
|
||||
},
|
||||
audioSnapCache: {
|
||||
key: "",
|
||||
sourceTrackId: "",
|
||||
@@ -1019,6 +1028,9 @@
|
||||
if (!timeline || !track || !point || direction === 0) {
|
||||
return false;
|
||||
}
|
||||
if (audioPointEditingLocked(track)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const snapData = getAudioSnapData(timeline);
|
||||
const snapTimes = snapData.values || [];
|
||||
@@ -1060,6 +1072,8 @@
|
||||
|
||||
setPointAbsoluteTime(track, point, targetTime, timeline);
|
||||
if (track.type === "audio-file") {
|
||||
markAudioTrackRenderDirty(track);
|
||||
scheduleAudioTrackRender(track);
|
||||
void syncAudioTracks(true);
|
||||
}
|
||||
renderAll();
|
||||
@@ -1201,7 +1215,6 @@
|
||||
return quantizeTrackValue(track, fallbackValue);
|
||||
}
|
||||
|
||||
sortTrackPoints(track);
|
||||
if (time <= track.points[0].time) {
|
||||
return track.points[0].value;
|
||||
}
|
||||
@@ -1230,6 +1243,39 @@
|
||||
return evaluatePointCurve(track, offset, defaultGain);
|
||||
}
|
||||
|
||||
function updateAudioTrackLevel(track, entry, offset, force = false) {
|
||||
if (!track || !entry?.element) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (audioTrackUsesRenderedFade(track) && entry.renderedUrl) {
|
||||
if (force || Math.abs((entry.element.volume ?? 1) - 1) >= 0.0005) {
|
||||
entry.element.volume = 1;
|
||||
}
|
||||
entry.lastVolume = 1;
|
||||
entry.lastVolumeAt = performance.now();
|
||||
return;
|
||||
}
|
||||
|
||||
const now = performance.now();
|
||||
const nextVolume = clamp(evaluateAudioGainAtOffset(track, Math.max(0, offset)) / 2, 0, 1);
|
||||
const previousVolume = Number.isFinite(entry.lastVolume) ? entry.lastVolume : Number.NaN;
|
||||
const volumeChanged = !Number.isFinite(previousVolume) || Math.abs(previousVolume - nextVolume) >= AUDIO_VOLUME_EPSILON;
|
||||
const throttleElapsed = (now - (entry.lastVolumeAt || 0)) >= AUDIO_VOLUME_THROTTLE_MS;
|
||||
const shouldUpdate = force || !state.transport.playing || !Number.isFinite(previousVolume) || (volumeChanged && throttleElapsed);
|
||||
|
||||
if (!shouldUpdate) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (force || Math.abs((entry.element.volume ?? 0) - nextVolume) >= 0.0005) {
|
||||
entry.element.volume = nextVolume;
|
||||
}
|
||||
|
||||
entry.lastVolume = nextVolume;
|
||||
entry.lastVolumeAt = now;
|
||||
}
|
||||
|
||||
function normalizeTrack(track) {
|
||||
const normalized = {
|
||||
...track,
|
||||
@@ -1411,6 +1457,9 @@
|
||||
runtime.audioTracks.set(trackId, {
|
||||
element: null,
|
||||
fileUrl: "",
|
||||
activeUrl: "",
|
||||
sourceFilePath: "",
|
||||
sourceBuffer: null,
|
||||
peaks: [],
|
||||
beatTimes: [],
|
||||
barTimes: [],
|
||||
@@ -1418,6 +1467,14 @@
|
||||
beatInterval: 0,
|
||||
duration: 0,
|
||||
estimatedBpm: 0,
|
||||
lastSeekAt: 0,
|
||||
lastVolume: Number.NaN,
|
||||
lastVolumeAt: 0,
|
||||
renderedUrl: "",
|
||||
renderSignature: "",
|
||||
renderDirty: false,
|
||||
renderPromise: null,
|
||||
renderError: "",
|
||||
analysisReady: false,
|
||||
decodeError: ""
|
||||
});
|
||||
@@ -1433,9 +1490,50 @@
|
||||
entry.element.removeAttribute("src");
|
||||
entry.element.load();
|
||||
}
|
||||
if (entry.renderedUrl) {
|
||||
URL.revokeObjectURL(entry.renderedUrl);
|
||||
}
|
||||
runtime.audioTracks.delete(trackId);
|
||||
}
|
||||
|
||||
function audioTrackUsesRenderedFade(track) {
|
||||
return track?.type === "audio-file" && Array.isArray(track.points) && track.points.length > 0;
|
||||
}
|
||||
|
||||
function audioPointEditingLocked(track) {
|
||||
return track?.type === "audio-file" && state.transport.playing;
|
||||
}
|
||||
|
||||
function buildAudioRenderSignature(track) {
|
||||
return JSON.stringify({
|
||||
filePath: track?.filePath || "",
|
||||
gain: clamp(Number(track?.gain) || 1, 0, 2),
|
||||
points: Array.isArray(track?.points)
|
||||
? track.points.map((point) => ({
|
||||
time: Number(point.time) || 0,
|
||||
value: quantizeTrackValue(track, point.value),
|
||||
mode: point.mode || "hold"
|
||||
}))
|
||||
: []
|
||||
});
|
||||
}
|
||||
|
||||
function markAudioTrackRenderDirty(track) {
|
||||
if (!track || track.type !== "audio-file") {
|
||||
return;
|
||||
}
|
||||
|
||||
const entry = getAudioTrackState(track.id);
|
||||
entry.renderDirty = true;
|
||||
entry.renderError = "";
|
||||
entry.renderSignature = "";
|
||||
|
||||
if (!audioTrackUsesRenderedFade(track) && entry.renderedUrl) {
|
||||
URL.revokeObjectURL(entry.renderedUrl);
|
||||
entry.renderedUrl = "";
|
||||
}
|
||||
}
|
||||
|
||||
function cleanupMissingAudioTracks() {
|
||||
const activeTrackIds = new Set(
|
||||
state.project.timelines.flatMap((timeline) => timeline.tracks)
|
||||
@@ -1458,6 +1556,231 @@
|
||||
return runtime.audioAnalysisContext;
|
||||
}
|
||||
|
||||
async function getDecodedAudioBuffer(track, forceRefresh = false) {
|
||||
if (!track?.filePath || !window.time4midi.audio?.readFile) {
|
||||
throw new Error("Audio-Datei nicht verfuegbar");
|
||||
}
|
||||
|
||||
const entry = getAudioTrackState(track.id);
|
||||
if (!forceRefresh && entry.sourceBuffer && entry.sourceFilePath === track.filePath) {
|
||||
return entry.sourceBuffer;
|
||||
}
|
||||
|
||||
const bufferData = await window.time4midi.audio.readFile(track.filePath);
|
||||
const context = getAudioAnalysisContext();
|
||||
if (!context) {
|
||||
throw new Error("AudioContext nicht verfuegbar");
|
||||
}
|
||||
|
||||
const decoded = await context.decodeAudioData(bufferData.slice(0));
|
||||
entry.sourceBuffer = decoded;
|
||||
entry.sourceFilePath = track.filePath;
|
||||
entry.duration = decoded.duration;
|
||||
return decoded;
|
||||
}
|
||||
|
||||
function encodeAudioBufferAsWav(audioBuffer) {
|
||||
const channels = Math.max(1, audioBuffer.numberOfChannels || 1);
|
||||
const sampleRate = audioBuffer.sampleRate || 44100;
|
||||
const frameCount = audioBuffer.length || 0;
|
||||
const bytesPerSample = 2;
|
||||
const blockAlign = channels * bytesPerSample;
|
||||
const dataSize = frameCount * blockAlign;
|
||||
const arrayBuffer = new ArrayBuffer(44 + dataSize);
|
||||
const view = new DataView(arrayBuffer);
|
||||
let offset = 0;
|
||||
|
||||
const writeString = (value) => {
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
view.setUint8(offset + index, value.charCodeAt(index));
|
||||
}
|
||||
offset += value.length;
|
||||
};
|
||||
|
||||
writeString("RIFF");
|
||||
view.setUint32(offset, 36 + dataSize, true);
|
||||
offset += 4;
|
||||
writeString("WAVE");
|
||||
writeString("fmt ");
|
||||
view.setUint32(offset, 16, true);
|
||||
offset += 4;
|
||||
view.setUint16(offset, 1, true);
|
||||
offset += 2;
|
||||
view.setUint16(offset, channels, true);
|
||||
offset += 2;
|
||||
view.setUint32(offset, sampleRate, true);
|
||||
offset += 4;
|
||||
view.setUint32(offset, sampleRate * blockAlign, true);
|
||||
offset += 4;
|
||||
view.setUint16(offset, blockAlign, true);
|
||||
offset += 2;
|
||||
view.setUint16(offset, bytesPerSample * 8, true);
|
||||
offset += 2;
|
||||
writeString("data");
|
||||
view.setUint32(offset, dataSize, true);
|
||||
offset += 4;
|
||||
|
||||
const channelData = Array.from({ length: channels }, (_entry, index) => audioBuffer.getChannelData(index));
|
||||
for (let frame = 0; frame < frameCount; frame += 1) {
|
||||
for (let channel = 0; channel < channels; channel += 1) {
|
||||
const sample = clamp(channelData[channel][frame] || 0, -1, 1);
|
||||
const intSample = sample < 0 ? sample * 0x8000 : sample * 0x7fff;
|
||||
view.setInt16(offset, Math.round(intSample), true);
|
||||
offset += 2;
|
||||
}
|
||||
}
|
||||
|
||||
return new Blob([arrayBuffer], { type: "audio/wav" });
|
||||
}
|
||||
|
||||
function applyAutomationToGainParam(gainNode, track, duration) {
|
||||
const defaultGain = clamp(Number(track.gain) || 1, 0, 2) / 2;
|
||||
const points = Array.isArray(track.points) ? track.points : [];
|
||||
if (!points.length) {
|
||||
gainNode.gain.setValueAtTime(defaultGain, 0);
|
||||
return;
|
||||
}
|
||||
|
||||
const firstValue = clamp(Number(points[0].value) || 0, 0, 2) / 2;
|
||||
gainNode.gain.setValueAtTime(firstValue, 0);
|
||||
gainNode.gain.setValueAtTime(firstValue, clamp(points[0].time, 0, duration));
|
||||
|
||||
for (let index = 0; index < points.length - 1; index += 1) {
|
||||
const current = points[index];
|
||||
const next = points[index + 1];
|
||||
const currentTime = clamp(Number(current.time) || 0, 0, duration);
|
||||
const nextTime = clamp(Number(next.time) || 0, currentTime, duration);
|
||||
const currentValue = clamp(Number(current.value) || 0, 0, 2) / 2;
|
||||
const nextValue = clamp(Number(next.value) || 0, 0, 2) / 2;
|
||||
|
||||
gainNode.gain.setValueAtTime(currentValue, currentTime);
|
||||
if (current.mode === "linear" && nextTime > currentTime) {
|
||||
gainNode.gain.linearRampToValueAtTime(nextValue, nextTime);
|
||||
} else {
|
||||
gainNode.gain.setValueAtTime(currentValue, nextTime);
|
||||
gainNode.gain.setValueAtTime(nextValue, nextTime);
|
||||
}
|
||||
}
|
||||
|
||||
const lastPoint = points[points.length - 1];
|
||||
const lastTime = clamp(Number(lastPoint.time) || 0, 0, duration);
|
||||
const lastValue = clamp(Number(lastPoint.value) || 0, 0, 2) / 2;
|
||||
gainNode.gain.setValueAtTime(lastValue, lastTime);
|
||||
gainNode.gain.setValueAtTime(lastValue, duration);
|
||||
}
|
||||
|
||||
async function prepareAudioTrackRender(track) {
|
||||
const entry = getAudioTrackState(track.id);
|
||||
|
||||
if (!audioTrackUsesRenderedFade(track)) {
|
||||
entry.renderDirty = false;
|
||||
entry.renderError = "";
|
||||
if (entry.renderedUrl) {
|
||||
URL.revokeObjectURL(entry.renderedUrl);
|
||||
entry.renderedUrl = "";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
const nextSignature = buildAudioRenderSignature(track);
|
||||
if (!entry.renderDirty && entry.renderedUrl && entry.renderSignature === nextSignature) {
|
||||
return entry.renderedUrl;
|
||||
}
|
||||
|
||||
if (entry.renderPromise) {
|
||||
return entry.renderPromise;
|
||||
}
|
||||
|
||||
entry.renderPromise = (async () => {
|
||||
refs.transportStatusLabel.textContent = `Audio Render: ${basenameFromPath(track.filePath)} wird vorbereitet...`;
|
||||
const sourceBuffer = await getDecodedAudioBuffer(track);
|
||||
const OfflineContextClass = window.OfflineAudioContext || window.webkitOfflineAudioContext;
|
||||
if (!OfflineContextClass) {
|
||||
throw new Error("OfflineAudioContext nicht verfuegbar");
|
||||
}
|
||||
|
||||
const offline = new OfflineContextClass(
|
||||
sourceBuffer.numberOfChannels,
|
||||
sourceBuffer.length,
|
||||
sourceBuffer.sampleRate
|
||||
);
|
||||
const source = offline.createBufferSource();
|
||||
const gainNode = offline.createGain();
|
||||
source.buffer = sourceBuffer;
|
||||
applyAutomationToGainParam(gainNode, track, sourceBuffer.duration);
|
||||
source.connect(gainNode);
|
||||
gainNode.connect(offline.destination);
|
||||
source.start(0);
|
||||
|
||||
const renderedBuffer = await offline.startRendering();
|
||||
const wavBlob = encodeAudioBufferAsWav(renderedBuffer);
|
||||
const renderedUrl = URL.createObjectURL(wavBlob);
|
||||
const latestSignature = buildAudioRenderSignature(track);
|
||||
const stillNeedsRenderedFade = audioTrackUsesRenderedFade(track);
|
||||
|
||||
if (!stillNeedsRenderedFade) {
|
||||
URL.revokeObjectURL(renderedUrl);
|
||||
if (entry.renderedUrl) {
|
||||
URL.revokeObjectURL(entry.renderedUrl);
|
||||
entry.renderedUrl = "";
|
||||
}
|
||||
entry.renderSignature = "";
|
||||
entry.renderDirty = false;
|
||||
return "";
|
||||
}
|
||||
|
||||
if (latestSignature !== nextSignature) {
|
||||
URL.revokeObjectURL(renderedUrl);
|
||||
entry.renderDirty = true;
|
||||
return entry.renderedUrl || "";
|
||||
}
|
||||
|
||||
if (entry.renderedUrl) {
|
||||
URL.revokeObjectURL(entry.renderedUrl);
|
||||
}
|
||||
|
||||
entry.renderedUrl = renderedUrl;
|
||||
entry.renderSignature = nextSignature;
|
||||
entry.renderDirty = false;
|
||||
entry.renderError = "";
|
||||
return renderedUrl;
|
||||
})().catch((error) => {
|
||||
entry.renderError = error.message || String(error);
|
||||
refs.transportStatusLabel.textContent = `Audio Render Fehler: ${entry.renderError}`;
|
||||
throw error;
|
||||
}).finally(() => {
|
||||
entry.renderPromise = null;
|
||||
if (entry.renderDirty && !state.transport.playing) {
|
||||
scheduleAudioTrackRender(track);
|
||||
}
|
||||
});
|
||||
|
||||
return entry.renderPromise;
|
||||
}
|
||||
|
||||
function scheduleAudioTrackRender(track) {
|
||||
if (!track || track.type !== "audio-file" || state.transport.playing || !track.filePath) {
|
||||
return;
|
||||
}
|
||||
|
||||
void prepareAudioTrackRender(track);
|
||||
}
|
||||
|
||||
async function prepareTimelineAudioForPlayback(timeline = getSelectedTimeline()) {
|
||||
if (!timeline) {
|
||||
return;
|
||||
}
|
||||
|
||||
const audioTracks = timeline.tracks.filter((track) => track.type === "audio-file" && track.filePath && audioTrackUsesRenderedFade(track));
|
||||
for (const track of audioTracks) {
|
||||
try {
|
||||
await prepareAudioTrackRender(track);
|
||||
} catch (_error) {
|
||||
// Keep transport responsive even if one render fails.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function buildWaveformPeaks(samples, desiredSamples = AUDIO_WAVEFORM_SAMPLES) {
|
||||
if (!samples || !samples.length) {
|
||||
return [];
|
||||
@@ -1612,13 +1935,7 @@
|
||||
entry.decodeError = "";
|
||||
|
||||
try {
|
||||
const bufferData = await window.time4midi.audio.readFile(track.filePath);
|
||||
const context = getAudioAnalysisContext();
|
||||
if (!context) {
|
||||
throw new Error("AudioContext nicht verfuegbar");
|
||||
}
|
||||
|
||||
const decoded = await context.decodeAudioData(bufferData.slice(0));
|
||||
const decoded = await getDecodedAudioBuffer(track, true);
|
||||
const channelData = decoded.numberOfChannels > 1
|
||||
? decoded.getChannelData(0).map((sample, index) => (sample + decoded.getChannelData(1)[index]) * 0.5)
|
||||
: decoded.getChannelData(0);
|
||||
@@ -1640,6 +1957,10 @@
|
||||
ensureTimelineCoversAudioTrack(timeline, track);
|
||||
}
|
||||
syncTrackDerivedValues(track);
|
||||
if (audioTrackUsesRenderedFade(track)) {
|
||||
markAudioTrackRenderDirty(track);
|
||||
scheduleAudioTrackRender(track);
|
||||
}
|
||||
renderAll();
|
||||
} catch (error) {
|
||||
entry.decodeError = error.message;
|
||||
@@ -1654,17 +1975,19 @@
|
||||
async function ensureAudioTrackReady(track) {
|
||||
const entry = getAudioTrackState(track.id);
|
||||
const fileUrl = window.time4midi.audio?.fileUrl ? window.time4midi.audio.fileUrl(track.filePath) : "";
|
||||
entry.fileUrl = fileUrl;
|
||||
if (!entry.element) {
|
||||
entry.element = new Audio();
|
||||
entry.element.preload = "auto";
|
||||
}
|
||||
if (fileUrl && entry.fileUrl !== fileUrl) {
|
||||
entry.fileUrl = fileUrl;
|
||||
entry.element.src = fileUrl;
|
||||
const preferredUrl = audioTrackUsesRenderedFade(track) && entry.renderedUrl ? entry.renderedUrl : fileUrl;
|
||||
if (preferredUrl && entry.activeUrl !== preferredUrl) {
|
||||
entry.activeUrl = preferredUrl;
|
||||
entry.element.src = preferredUrl;
|
||||
entry.duration = 0;
|
||||
entry.element.load();
|
||||
}
|
||||
entry.element.volume = evaluateAudioGainAtOffset(track, 0) / 2;
|
||||
updateAudioTrackLevel(track, entry, 0, true);
|
||||
if (state.routing.audioOutputId && typeof entry.element.setSinkId === "function") {
|
||||
try {
|
||||
await entry.element.setSinkId(state.routing.audioOutputId);
|
||||
@@ -1675,6 +1998,9 @@
|
||||
if (!entry.analysisReady && !entry.decodeError && track.filePath) {
|
||||
void analyzeAudioTrack(track);
|
||||
}
|
||||
if (audioTrackUsesRenderedFade(track) && entry.renderDirty && !state.transport.playing) {
|
||||
void prepareAudioTrackRender(track);
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
|
||||
@@ -1712,42 +2038,84 @@
|
||||
}
|
||||
|
||||
async function syncAudioTracks(forceSeek = false) {
|
||||
const timeline = getSelectedTimeline();
|
||||
if (!timeline) {
|
||||
pauseAllAudioTracks();
|
||||
if (runtime.audioSync.running) {
|
||||
runtime.audioSync.pending = true;
|
||||
runtime.audioSync.pendingForceSeek = runtime.audioSync.pendingForceSeek || forceSeek;
|
||||
return;
|
||||
}
|
||||
|
||||
const audioTracks = timeline.tracks.filter((track) => track.type === "audio-file" && track.filePath);
|
||||
for (const track of audioTracks) {
|
||||
const entry = await ensureAudioTrackReady(track);
|
||||
const offset = state.transport.currentTime - track.startTime;
|
||||
const duration = entry.duration || track.analysisDuration || Number.MAX_SAFE_INTEGER;
|
||||
const active = offset >= 0 && offset <= duration;
|
||||
entry.element.volume = evaluateAudioGainAtOffset(track, Math.max(0, offset)) / 2;
|
||||
runtime.audioSync.running = true;
|
||||
runtime.audioSync.pending = false;
|
||||
runtime.audioSync.pendingForceSeek = false;
|
||||
|
||||
if (!active) {
|
||||
entry.element.pause();
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
let nextForceSeek = forceSeek;
|
||||
|
||||
if (forceSeek || Math.abs((entry.element.currentTime || 0) - offset) > AUDIO_SYNC_TOLERANCE) {
|
||||
try {
|
||||
entry.element.currentTime = Math.max(0, offset);
|
||||
} catch (_error) {
|
||||
// Ignore seek races while metadata is loading.
|
||||
do {
|
||||
const timeline = getSelectedTimeline();
|
||||
runtime.audioSync.pending = false;
|
||||
runtime.audioSync.pendingForceSeek = false;
|
||||
|
||||
if (!timeline) {
|
||||
pauseAllAudioTracks();
|
||||
nextForceSeek = false;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (state.transport.playing) {
|
||||
try {
|
||||
await entry.element.play();
|
||||
} catch (_error) {
|
||||
// Autoplay issues are non-fatal; the transport still runs.
|
||||
const audioTracks = timeline.tracks.filter((track) => track.type === "audio-file" && track.filePath);
|
||||
for (const track of audioTracks) {
|
||||
if (audioTrackUsesRenderedFade(track)) {
|
||||
try {
|
||||
await prepareAudioTrackRender(track);
|
||||
} catch (_error) {
|
||||
// Fall back to the original file if the offline render fails.
|
||||
}
|
||||
}
|
||||
const entry = await ensureAudioTrackReady(track);
|
||||
const offset = state.transport.currentTime - track.startTime;
|
||||
const duration = entry.duration || track.analysisDuration || Number.MAX_SAFE_INTEGER;
|
||||
const active = offset >= 0 && offset <= duration;
|
||||
updateAudioTrackLevel(track, entry, offset, nextForceSeek);
|
||||
|
||||
if (!active) {
|
||||
if (!entry.element.paused) {
|
||||
entry.element.pause();
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const now = performance.now();
|
||||
const drift = Math.abs((entry.element.currentTime || 0) - offset);
|
||||
const seekTolerance = state.transport.playing ? AUDIO_SYNC_TOLERANCE_PLAYING : AUDIO_SYNC_TOLERANCE;
|
||||
const canSeekNow = nextForceSeek || !state.transport.playing || ((now - (entry.lastSeekAt || 0)) >= AUDIO_SYNC_THROTTLE_MS);
|
||||
if (canSeekNow && drift > seekTolerance) {
|
||||
try {
|
||||
entry.element.currentTime = Math.max(0, offset);
|
||||
entry.lastSeekAt = now;
|
||||
} catch (_error) {
|
||||
// Ignore seek races while metadata is loading.
|
||||
}
|
||||
}
|
||||
|
||||
if (state.transport.playing) {
|
||||
if (entry.element.paused) {
|
||||
try {
|
||||
await entry.element.play();
|
||||
} catch (_error) {
|
||||
// Autoplay issues are non-fatal; the transport still runs.
|
||||
}
|
||||
}
|
||||
} else if (!entry.element.paused) {
|
||||
entry.element.pause();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
entry.element.pause();
|
||||
}
|
||||
|
||||
nextForceSeek = runtime.audioSync.pendingForceSeek;
|
||||
} while (runtime.audioSync.pending);
|
||||
} finally {
|
||||
runtime.audioSync.running = false;
|
||||
runtime.audioSync.pending = false;
|
||||
runtime.audioSync.pendingForceSeek = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2206,7 +2574,6 @@
|
||||
|
||||
const startTime = Math.max(0, Number(track.startTime) || 0);
|
||||
const color = getTrackColor(track);
|
||||
sortTrackPoints(track);
|
||||
|
||||
ctx.strokeStyle = color;
|
||||
ctx.lineWidth = 2;
|
||||
@@ -2248,7 +2615,6 @@
|
||||
drawAudioTrackAutomation(track, trackIndex, duration, metrics);
|
||||
return;
|
||||
}
|
||||
sortTrackPoints(track);
|
||||
const color = getTrackColor(track);
|
||||
ctx.strokeStyle = color;
|
||||
ctx.lineWidth = 2;
|
||||
@@ -2480,6 +2846,8 @@
|
||||
const range = trackRange(track);
|
||||
const knxDptMeta = track.type === "knx-value" ? getKnxDptMeta(track.dpt) : null;
|
||||
const mappingPicker = renderTrackMappingPicker(track);
|
||||
const audioPointLocked = track.type === "audio-file" && state.transport.playing;
|
||||
const audioPointDisabled = audioPointLocked ? "disabled" : "";
|
||||
let typeFields = "";
|
||||
|
||||
if (track.type === "midi-note") {
|
||||
@@ -2687,19 +3055,20 @@
|
||||
if (point) {
|
||||
pointFields = `
|
||||
<div class="section-label">Punkt</div>
|
||||
${audioPointLocked ? `<div class="info-card">Audio-Fade-Punkte sind waehrend der Wiedergabe gesperrt. Stoppen oder pausieren, dann weiter bearbeiten.</div>` : ""}
|
||||
<div class="field-grid">
|
||||
<label class="field">
|
||||
<span>${track.type === "audio-file" ? "Zeit im Clip" : "Zeit"}</span>
|
||||
<input data-bind="point.time" type="number" min="0" max="${track.type === "audio-file" ? getAudioTrackDuration(track).toFixed(2) : timeline?.duration || 0}" step="0.01" value="${point.time.toFixed(2)}">
|
||||
<input data-bind="point.time" type="number" min="0" max="${track.type === "audio-file" ? getAudioTrackDuration(track).toFixed(2) : timeline?.duration || 0}" step="0.01" value="${point.time.toFixed(2)}" ${track.type === "audio-file" ? audioPointDisabled : ""}>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>${track.type === "audio-file" ? "Gain" : "Wert"}</span>
|
||||
<input data-bind="point.value" type="number" min="${range.min}" max="${range.max}" step="${range.step}" value="${point.value}">
|
||||
<input data-bind="point.value" type="number" min="${range.min}" max="${range.max}" step="${range.step}" value="${point.value}" ${track.type === "audio-file" ? audioPointDisabled : ""}>
|
||||
</label>
|
||||
</div>
|
||||
<label class="field">
|
||||
<span>Interpolation zum naechsten Punkt</span>
|
||||
<select data-bind="point.mode">
|
||||
<select data-bind="point.mode" ${track.type === "audio-file" ? audioPointDisabled : ""}>
|
||||
${pointModeOptions(track, point)}
|
||||
</select>
|
||||
</label>
|
||||
@@ -3318,6 +3687,7 @@
|
||||
duration: 120,
|
||||
beatSnapEnabled: false,
|
||||
beatSnapGrid: "1",
|
||||
beatSnapLineWidth: "2",
|
||||
tracks: []
|
||||
};
|
||||
state.project.timelines.push(timeline);
|
||||
@@ -3813,6 +4183,7 @@
|
||||
state.transport.currentTime = 0;
|
||||
}
|
||||
void syncAudioTracks(true);
|
||||
void prepareTimelineAudioForPlayback();
|
||||
renderTransportState();
|
||||
drawCanvas();
|
||||
}
|
||||
@@ -3823,7 +4194,12 @@
|
||||
}
|
||||
}
|
||||
|
||||
function startInternalTransport() {
|
||||
async function startInternalTransport() {
|
||||
const timeline = getSelectedTimeline();
|
||||
if (!timeline) {
|
||||
return;
|
||||
}
|
||||
await prepareTimelineAudioForPlayback(timeline);
|
||||
state.transport.playing = true;
|
||||
state.transport.playStartPerf = performance.now();
|
||||
state.transport.playStartTime = state.transport.currentTime;
|
||||
@@ -3855,7 +4231,7 @@
|
||||
if (state.transport.syncSource !== "internal") {
|
||||
return;
|
||||
}
|
||||
startInternalTransport();
|
||||
void startInternalTransport();
|
||||
}
|
||||
|
||||
function tickTransport() {
|
||||
@@ -4125,6 +4501,10 @@
|
||||
if (!timeline) return;
|
||||
const hit = findCanvasHit(x, y);
|
||||
if (!hit || !hit.track) return;
|
||||
if (audioPointEditingLocked(hit.track)) {
|
||||
refs.transportStatusLabel.textContent = "Audio-Fade-Punkte koennen waehrend der Wiedergabe nicht bearbeitet werden.";
|
||||
return;
|
||||
}
|
||||
const metrics = getPlotMetrics();
|
||||
let pointTime = Number(xToTime(x, timeline.duration, metrics).toFixed(2));
|
||||
pointTime = snapTimelineTimeToAudioGrid(pointTime, timeline, hit.track);
|
||||
@@ -4148,6 +4528,8 @@
|
||||
};
|
||||
hit.track.points.push(point);
|
||||
sortTrackPoints(hit.track);
|
||||
markAudioTrackRenderDirty(hit.track);
|
||||
scheduleAudioTrackRender(hit.track);
|
||||
state.selectedTrackId = hit.track.id;
|
||||
state.selectedPointId = point.id;
|
||||
renderAll();
|
||||
@@ -4158,6 +4540,7 @@
|
||||
const track = getSelectedTrack();
|
||||
const point = getSelectedPoint();
|
||||
if (!binding) return;
|
||||
const pointEditLocked = audioPointEditingLocked(track);
|
||||
|
||||
if (binding === "timeline.name" && timeline) {
|
||||
timeline.name = rawValue;
|
||||
@@ -4228,12 +4611,17 @@
|
||||
void syncAudioTracks(true);
|
||||
} else if (binding === "track.gain" && track) {
|
||||
track.gain = clamp(Number(rawValue) || 1, 0, 2);
|
||||
markAudioTrackRenderDirty(track);
|
||||
const entry = runtime.audioTracks.get(track.id);
|
||||
if (entry?.element) {
|
||||
const offset = Math.max(0, state.transport.currentTime - track.startTime);
|
||||
entry.element.volume = evaluateAudioGainAtOffset(track, offset) / 2;
|
||||
updateAudioTrackLevel(track, entry, offset, true);
|
||||
}
|
||||
scheduleAudioTrackRender(track);
|
||||
} else if (binding === "point.time" && point && timeline && track) {
|
||||
if (pointEditLocked) {
|
||||
return;
|
||||
}
|
||||
point.time = track.type === "audio-file"
|
||||
? clamp(Number(rawValue) || 0, 0, getAudioTrackDuration(track))
|
||||
: clamp(
|
||||
@@ -4242,10 +4630,22 @@
|
||||
timeline.duration
|
||||
);
|
||||
sortTrackPoints(track);
|
||||
markAudioTrackRenderDirty(track);
|
||||
scheduleAudioTrackRender(track);
|
||||
} else if (binding === "point.value" && point && track) {
|
||||
if (pointEditLocked) {
|
||||
return;
|
||||
}
|
||||
point.value = quantizeTrackValue(track, rawValue);
|
||||
markAudioTrackRenderDirty(track);
|
||||
scheduleAudioTrackRender(track);
|
||||
} else if (binding === "point.mode" && point) {
|
||||
if (pointEditLocked) {
|
||||
return;
|
||||
}
|
||||
point.mode = rawValue;
|
||||
markAudioTrackRenderDirty(track);
|
||||
scheduleAudioTrackRender(track);
|
||||
}
|
||||
|
||||
if (track && ["track.minValue", "track.maxValue", "track.registerType", "track.dpt"].includes(binding)) {
|
||||
@@ -4703,6 +5103,10 @@
|
||||
}
|
||||
|
||||
if (hit.point) {
|
||||
if (audioPointEditingLocked(hit.track)) {
|
||||
renderAll();
|
||||
return;
|
||||
}
|
||||
const pointerOriginTime = Number(xToTime(x, Math.max(1, getSelectedTimeline()?.duration || 1), getPlotMetrics()).toFixed(3));
|
||||
const pointOriginTime = getCurrentPointAbsoluteTime(hit.track, hit.point);
|
||||
const snapData = getAudioSnapData(getSelectedTimeline());
|
||||
@@ -4772,6 +5176,9 @@
|
||||
const track = timeline.tracks.find((entry) => entry.id === runtime.drag.trackId);
|
||||
const point = track?.points.find((entry) => entry.id === runtime.drag.pointId);
|
||||
if (!track || !point) return;
|
||||
if (audioPointEditingLocked(track)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const metrics = getPlotMetrics();
|
||||
const clampedX = clamp(x, metrics.left, metrics.right);
|
||||
@@ -4800,6 +5207,10 @@
|
||||
|
||||
const timeline = getSelectedTimeline();
|
||||
const track = timeline?.tracks.find((entry) => entry.id === completedDrag.trackId) || null;
|
||||
if (completedDrag.type === "point" && track?.type === "audio-file") {
|
||||
markAudioTrackRenderDirty(track);
|
||||
scheduleAudioTrackRender(track);
|
||||
}
|
||||
if (track?.type === "audio-file") {
|
||||
void syncAudioTracks(true);
|
||||
}
|
||||
@@ -4850,8 +5261,14 @@
|
||||
if (!track || !state.selectedPointId) {
|
||||
return;
|
||||
}
|
||||
if (audioPointEditingLocked(track)) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
track.points = track.points.filter((point) => point.id !== state.selectedPointId);
|
||||
markAudioTrackRenderDirty(track);
|
||||
scheduleAudioTrackRender(track);
|
||||
state.selectedPointId = "";
|
||||
renderAll();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user