Skip to content

Commit b24ade7

Browse files
fix(web): length / playback / preview consistency audit
The Length input could leave playback in a state that drifted from the visible timeline. Audit + fixes: - setTotalMs now clamps every block to the new total: blocks past the new end shrink to a 20ms tail at the very edge instead of vanishing; partially-overflowing blocks truncate. NaN-safe. - Compose now stops the live pattern handle when totalMs or lanes change mid-playback; otherwise a length tweak left the audible pattern out of sync with what the playhead and waveform showed. - onViewChange clamps view to [0, totalMs] so a length shrink doesn't leave the preview pointing past the rendered buffer. - play() now drives a `playing` ref → PreviewPanel shows a small red "playing" indicator, and onEnded clears it. - play() schedules a belt-and-braces timeout to clear the playing flag after the visual span in case onEnded never fires.
1 parent 0453ac4 commit b24ade7

3 files changed

Lines changed: 63 additions & 8 deletions

File tree

web/src/components/PreviewPanel.vue

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -284,7 +284,7 @@ onUnmounted(() => {
284284
</script>
285285

286286
<template>
287-
<section class="relative flex-1 flex flex-col bg-stone-900 text-paper rounded-2xl m-3 p-4 min-h-0 overflow-hidden">
287+
<section class="relative flex-1 flex flex-col bg-stone-900 text-paper p-4 min-h-0 overflow-hidden">
288288
<header class="flex items-center gap-3 pb-2">
289289
<button
290290
type="button"
@@ -298,7 +298,14 @@ onUnmounted(() => {
298298
</svg>
299299
</button>
300300
<div class="flex flex-col">
301-
<span class="font-mono text-[10px] uppercase tracking-wider opacity-50">Total</span>
301+
<span class="flex items-center gap-1.5 font-mono text-[10px] uppercase tracking-wider opacity-50">
302+
Total
303+
<span
304+
v-if="props.playing"
305+
class="inline-block h-1.5 w-1.5 rounded-full bg-red-500 animate-pulse"
306+
title="Playing"
307+
/>
308+
</span>
302309
<span class="font-mono text-[14px] tabular-nums">{{ totalLabel }}</span>
303310
</div>
304311
<div class="flex flex-col">
@@ -310,7 +317,7 @@ onUnmounted(() => {
310317
</div>
311318
</header>
312319

313-
<div class="relative flex-1 min-h-0 rounded-md overflow-hidden bg-stone-800/50">
320+
<div class="relative flex-1 min-h-0 overflow-hidden bg-stone-800/40">
314321
<canvas ref="canvasEl" class="absolute inset-0 w-full h-full" />
315322
<div
316323
class="absolute top-0 bottom-0 w-px bg-paper/80"

web/src/composables/useBuilder.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -392,7 +392,24 @@ export function useBuilder(opts: UseBuilderOptions = {}): UseBuilder {
392392
/* timeline controls */
393393

394394
function setTotalMs(ms: number): void {
395-
state.totalMs = Math.max(MIN_TOTAL, Math.min(MAX_TOTAL, ms))
395+
if (!Number.isFinite(ms)) return
396+
const next = Math.max(MIN_TOTAL, Math.min(MAX_TOTAL, ms))
397+
state.totalMs = next
398+
// Clamp every block to fit the new timeline. Blocks that started past
399+
// the new end shrink to a 20ms minimum at the very end; blocks that
400+
// partially overflow get truncated. We don't auto-delete — that would
401+
// be too destructive on a typo'd length.
402+
for (const lane of state.lanes) {
403+
for (const b of lane.blocks) {
404+
if (b.position >= next) {
405+
// Squash to a 20ms tail at the very end so the block isn't lost.
406+
b.position = Math.max(0, next - 20)
407+
b.duration = Math.min(20, next - b.position)
408+
} else if (b.position + b.duration > next) {
409+
b.duration = Math.max(20, next - b.position)
410+
}
411+
}
412+
}
396413
}
397414

398415
function setPxPerMs(v: number): void {

web/src/pages/Compose.vue

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -95,19 +95,37 @@ function formatTime(ms: number): string {
9595
const timelineRef = ref<InstanceType<typeof Timeline> | null>(null)
9696
const previewRef = ref<InstanceType<typeof PreviewPanel> | null>(null)
9797
let liveHandle: PlayHandle | null = null
98+
const playing = ref(false)
9899
99100
/** Visible ms window of the timeline, mirrored to the preview. */
100101
const view = ref<{ startMs: number; endMs: number }>({ startMs: 0, endMs: 10_000 })
101102
function onViewChange(payload: { startMs: number; endMs: number }): void {
102-
view.value = payload
103+
// Clamp to the current totalMs so a length shrink doesn't leave the
104+
// preview reading samples past the buffer.
105+
const total = builder.state.totalMs
106+
view.value = {
107+
startMs: Math.max(0, Math.min(total, payload.startMs)),
108+
endMs: Math.max(0, Math.min(total, payload.endMs)),
109+
}
103110
}
104111
105112
async function play(): Promise<void> {
106113
if (builder.steps.value.length === 0) return
107114
if (liveHandle) liveHandle.stop()
108-
timelineRef.value?.runPlayhead(builder.total.value)
109-
previewRef.value?.runPlayhead(builder.total.value)
115+
const span = builder.total.value
116+
timelineRef.value?.runPlayhead(span)
117+
previewRef.value?.runPlayhead(span)
118+
playing.value = true
110119
liveHandle = await seslen.playPattern(builder.steps.value)
120+
liveHandle.onEnded(() => {
121+
playing.value = false
122+
if (liveHandle) liveHandle = null
123+
})
124+
// Belt-and-braces: even if onEnded never fires (e.g. error path), make
125+
// sure the indicator clears at the end of the visual playhead.
126+
setTimeout(() => {
127+
playing.value = false
128+
}, span + 80)
111129
}
112130
113131
function clearAll(): void {
@@ -408,6 +426,19 @@ const lengthInputSec = computed<number>({
408426
function setZoom(v: number): void {
409427
builder.setPxPerMs(v)
410428
}
429+
430+
/* If the user changes length, lanes, or steps mid-playback, kill the live
431+
* pattern. Otherwise the audible play diverges from the visible timeline. */
432+
watch(
433+
() => [builder.state.totalMs, builder.state.lanes] as const,
434+
() => {
435+
if (liveHandle) {
436+
liveHandle.stop()
437+
liveHandle = null
438+
}
439+
},
440+
{ deep: true },
441+
)
411442
</script>
412443

413444
<template>
@@ -524,7 +555,7 @@ function setZoom(v: number): void {
524555
:view-start-ms="view.startMs"
525556
:view-end-ms="view.endMs"
526557
:has-content="builder.steps.value.length > 0"
527-
:playing="false"
558+
:playing="playing"
528559
@play="play"
529560
/>
530561
</div>

0 commit comments

Comments
 (0)