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
23 changes: 23 additions & 0 deletions docs/loading.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# Resource loading and playback

Playback becomes available only after the engine, packaged textures, every hit
sound, the chart, and all supplied music, timing configuration and cover sources
have finished downloading and decoding. Omitting optional sources still supports
silent charts and the stock stage. A supplied resource that fails keeps playback
disabled and displays an error; source failures can be retried in the player.
Engine or bundled-asset failures require remounting the component or reloading the page.

Changing a source pauses playback immediately. A retired download cannot publish
`loaded` or trigger autoplay for a newer selection. Buttons, canvas clicks,
keyboard shortcuts, exposed `play`/`toggle`/`seek` methods and autoplay share the
same readiness check. Cancelling a play request while the browser resumes its
AudioContext also prevents it from starting later.

Hosts fetching Blob sources themselves must pass `:pending="loading"` until their
entire source set is available. Set it before replacing the sources. Without this
prop the player cannot distinguish an omitted optional source from one that the
host has not yet provided. `ready` still means engine initialization;
`loaded` is emitted only after the current complete source set is ready.

Run `npm test` for the loading, transport and native preview regressions, and
`npm run build:lib` to rebuild the distributable Vue component.
24 changes: 24 additions & 0 deletions docs/refresh-preview.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# Refresh chart preview

The viewer uses the `wds-editor` Refresh core pinned by `third_party/wds-editor`
(commit `6fc30e2`). It automatically recognizes extended CSV geometry without a
manual profile switch. Notes may overhang either side of the twelve-lane stage
or be wider than the stage, but must still overlap a playable lane.

The same core supports positive SV rows (`type=-1`), nontail holds
(`1100/1101/1110/1111`), authored `900` judgments, native `.wdschart` v6 and the
editor's extended SUS metadata. The browser adapter passes each loaded chart's
SV timeline to stage geometry as well as the core snapshot builder. Loading an
ordinary chart clears the preceding chart's SV timeline. SV changes visual
scrolling, not music or judgment times; nontail holds do not acquire artificial
tail judgments or generated eighths.

Build and exercise the actual browser WASM and packaged skins:

```sh
npm run build:lib
npm run test:wasm
```

`SIRIUS_VIEWER_WASM_DIR` can point the tests at a host application's copied
`wasm/` directory, verifying the deployed integration uses the same core.
5 changes: 5 additions & 0 deletions native/src/viewer.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,11 @@ EMSCRIPTEN_KEEPALIVE int wv_load_chart(const char* chart_path, const char* music
return 0;
}
v.last_error.clear();
// ChartPreviewPanel wires the document's visual SV timeline into stage geometry.
// The snapshot builder already reads the profile and SVs from the new core;
// ribbons/arrows also need this timeline, including clearing it on chart changes.
v.preview.geometry().set_scroll_speed_points(v.engine.document().scroll_speed_points(),
v.engine.document().timing());
v.engine.seek(0);
return 1;
}
Expand Down
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,15 @@
"./assets/*": "./dist-lib/assets/*"
},
"scripts": {
"test:wasm": "node --test tests/wasm-preview.test.mjs",
"sync:assets": "node ./scripts/sync-assets.mjs",
"build:wasm": "node ./scripts/build-wasm.mjs",
"dev": "npm run sync:assets && npm run build:wasm && vite",
"build": "npm run sync:assets && npm run build:wasm && vue-tsc --noEmit && vite build",
"typecheck": "vue-tsc --noEmit",
"preview": "vite preview",
"build:lib": "npm run sync:assets && npm run build:wasm && node ./scripts/build-lib.mjs"
"build:lib": "npm run sync:assets && npm run build:wasm && node ./scripts/build-lib.mjs",
"test": "node --test tests/*.test.mjs"
},
"peerDependencies": {
"vue": "^3.4.0"
Expand Down
140 changes: 65 additions & 75 deletions src/components/SiriusChartViewer.vue
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ const props = withDefaults(defineProps<{
assetBase?: string
/** Start playing as soon as everything is loaded (subject to browser autoplay rules). */
autoplay?: boolean
/** Host is still fetching sources; all playback stays blocked until false. */
pending?: boolean
/** fetch() options for URL sources (e.g. auth headers). */
fetchInit?: RequestInit
}>(), {
Expand All @@ -31,6 +33,7 @@ const props = withDefaults(defineProps<{
cover: null,
assetBase: '/sirius-chart-viewer/',
autoplay: false,
pending: false,
fetchInit: undefined,
})

Expand Down Expand Up @@ -62,6 +65,7 @@ let uiRaf = 0
let disposed = false

const hasChart = computed(() => !!chartInfo.value)
const canPlay = computed(() => engineReady.value && hasChart.value && !loading.value && !props.pending && !error.value && !disposed)

function formatTime(sec: number) {
const sign = sec < 0 ? '-' : ''
Expand Down Expand Up @@ -97,85 +101,69 @@ function fail(e: unknown) {
emit('error', error.value)
}

async function loadMusic() {
const v = viewer.value
if (!v) return
const bytes = props.music ? await readSource(props.music, props.fetchInit) : null
await v.loadMusic(bytes)
}

async function loadChart() {
const v = viewer.value
if (!v) return
if (!props.chart) {
chartInfo.value = undefined
return
}
const [data, musicConfig] = await Promise.all([
readSource(props.chart, props.fetchInit),
props.musicConfig ? readSource(props.musicConfig, props.fetchInit) : Promise.resolve(undefined),
])
const name = props.chartName || sourceName(props.chart) || 'chart.csv'
chartInfo.value = v.loadChart({ name, data, musicConfig })
emit('loaded', chartInfo.value)
}

async function loadCover() {
const v = viewer.value
if (!v) return
try {
await v.setCover(props.cover ? await readSource(props.cover, props.fetchInit) : null)
} catch (e) {
// A broken jacket must not block playback; keep the stock screens.
console.warn('[SiriusChartViewer] cover:', e)
}
}

// Source changes are coalesced: a change arriving mid-load is picked up by the
// running loop (music before chart so the timeline knows the BGM length).
const dirty = { music: false, chart: false, cover: false }
// Every generation is a complete source set. A slow retired load can never
// unlock playback or publish a loaded event for a newer selection.
let generation = 0
let running = false
let request: AbortController | null = null

function reload(parts: Partial<typeof dirty>) {
dirty.music ||= !!parts.music
dirty.chart ||= !!parts.chart
dirty.cover ||= !!parts.cover
if (running || !viewer.value) return
running = true
void runReload().finally(() => (running = false))
function reload() {
generation++
request?.abort()
viewer.value?.transport.pause()
playing.value = false
loading.value = true
error.value = ''
if (!viewer.value || running || props.pending || disposed) return
void runReload()
}

async function runReload() {
loading.value = true
error.value = ''
running = true
try {
while ((dirty.music || dirty.chart || dirty.cover) && !disposed) {
if (dirty.music) {
dirty.music = false
await loadMusic()
} else if (dirty.cover) {
dirty.cover = false
await loadCover()
} else {
dirty.chart = false
await loadChart()
while (!disposed && !props.pending) {
const version = generation
const v = viewer.value!
const controller = new AbortController()
request = controller
const current = () => !disposed && version === generation && !props.pending
const init = { ...props.fetchInit, signal: props.fetchInit?.signal
? AbortSignal.any([controller.signal, props.fetchInit.signal]) : controller.signal }
const chartSource = props.chart
const name = props.chartName || sourceName(chartSource) || 'chart.csv'
try {
const [data, config, audio, jacket] = await Promise.all([
chartSource ? readSource(chartSource, init) : null,
props.musicConfig ? readSource(props.musicConfig, init) : undefined,
props.music ? readSource(props.music, init) : null,
props.cover ? readSource(props.cover, init) : null,
])
if (!current()) continue
await v.loadMusic(audio)
if (!current()) continue
await v.setCover(jacket)
if (!current()) continue
chartInfo.value = data ? v.loadChart({ name, data, musicConfig: config }) : undefined
loading.value = false
if (chartInfo.value) emit('loaded', chartInfo.value)
if (current() && props.autoplay) await play()
} catch (e) {
if (current()) { loading.value = false; fail(e) }
}
if (current()) break
}
if (props.autoplay && hasChart.value && !disposed) await play()
} catch (e) {
if (!disposed) fail(e)
} finally {
loading.value = false
running = false
request = null
}
}

watch(() => [props.chart, props.chartName, props.musicConfig], () => reload({ chart: true }))
watch(() => props.music, () => reload({ music: true }))
watch(() => props.cover, () => reload({ cover: true }))
watch(() => [props.chart, props.chartName, props.musicConfig, props.music, props.cover, props.pending, props.fetchInit],
reload, { flush: 'sync' })

async function play() {
const v = viewer.value
if (!v || !hasChart.value) return
if (!v || !canPlay.value) return
try {
await v.transport.play()
} catch (e) {
Expand All @@ -193,7 +181,7 @@ function toggle() {
}

function seek(t: number) {
viewer.value?.transport.seek(t)
if (canPlay.value) viewer.value?.transport.seek(t)
}

function onSeekInput(e: Event) {
Expand Down Expand Up @@ -244,18 +232,18 @@ let instance: ChartViewer | null = null

onMounted(async () => {
uiRaf = requestAnimationFrame(syncUi)
const v = new ChartViewer(canvas.value!)
instance = v
v.transport.onEnded = () => emit('ended')
try {
const v = new ChartViewer(canvas.value!)
instance = v
v.transport.onEnded = () => emit('ended')
await v.init(props.assetBase)
if (disposed) return
viewer.value = v
applySettings()
engineReady.value = true
emit('ready')
loading.value = false
reload({ music: !!props.music, chart: !!props.chart, cover: !!props.cover })
reload()
} catch (e) {
loading.value = false
if (!disposed) fail(e)
Expand All @@ -264,6 +252,8 @@ onMounted(async () => {

onBeforeUnmount(() => {
disposed = true
generation++
request?.abort()
cancelAnimationFrame(uiRaf)
instance?.dispose()
instance = null
Expand All @@ -278,28 +268,28 @@ defineExpose({ play, pause, toggle, seek, viewer })
<section class="scv__stage">
<canvas ref="canvas" @click="toggle" @dblclick.stop="toggleFullscreen" />

<div v-if="loading" class="scv__overlay scv__overlay--dim">
<div v-if="loading || pending" class="scv__overlay scv__overlay--dim" role="status" aria-live="polite">
<span class="scv__spinner" />
<span>{{ engineReady ? '正在加载谱面…' : '正在加载预览引擎…' }}</span>
<span>{{ engineReady ? '正在加载谱面、音乐与画面资源…' : '正在加载预览引擎…' }}</span>
</div>
<div v-else-if="engineReady && !hasChart && !error" class="scv__overlay">
<slot name="empty">没有谱面</slot>
</div>
<button
v-else-if="hasChart && !playing && !error"
v-else-if="canPlay && !playing"
class="scv__big-play"
aria-label="播放"
@click="play"
>
▶
</button>
<div v-if="error" class="scv__error" role="alert" @click="error = ''">{{ error }}</div>
<div v-if="error" class="scv__error" role="alert">{{ error }} <button v-if="engineReady" type="button" @click="reload">重试加载</button></div>

<SettingsPanel v-if="showSettings" v-model="settings" class="scv__settings" @close="showSettings = false" />
</section>

<footer class="scv__controls">
<button class="scv__play" :disabled="!hasChart" :aria-label="playing ? '暂停' : '播放'" @click="toggle">
<button class="scv__play" :disabled="!canPlay" :aria-label="playing ? '暂停' : '播放'" @click="toggle">
{{ playing ? '❚❚' : '▶' }}
</button>
<span class="scv__time">{{ formatTime(time) }}</span>
Expand All @@ -311,7 +301,7 @@ defineExpose({ play, pause, toggle, seek, viewer })
:max="Math.max(duration, startTime + 0.01)"
step="0.01"
:value="time"
:disabled="!hasChart"
:disabled="!canPlay"
@input="onSeekInput"
/>
<span class="scv__time scv__time--total">{{ formatTime(duration) }}</span>
Expand Down
29 changes: 25 additions & 4 deletions src/lib/audioTransport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ export class AudioTransport {
private buffer: AudioBuffer | null = null
private source: AudioBufferSourceNode | null = null
private playing = false
private playRequest = 0
private musicRequest = 0
private musicLoading = false
private disposed = false
private rate = 1
/** Music time at `anchorCtx`. */
private anchorPos = 0
Expand Down Expand Up @@ -56,9 +60,16 @@ export class AudioTransport {
}

async setMusic(data: ArrayBuffer | null) {
this.stopSource()
this.buffer = data ? await this.ctx.decodeAudioData(data.slice(0)) : null
this.restartIfPlaying()
this.pause()
const request = ++this.musicRequest
this.musicLoading = true
this.buffer = null
try {
const buffer = data ? await this.ctx.decodeAudioData(data.slice(0)) : null
if (!this.disposed && request === this.musicRequest) this.buffer = buffer
} finally {
if (request === this.musicRequest) this.musicLoading = false
}
}

/** Chart delay (ms) and last-note time (ms), both in music time. */
Expand All @@ -80,21 +91,31 @@ export class AudioTransport {
}

async play() {
if (this.playing) return
if (this.playing || this.musicLoading || this.disposed) return
const request = ++this.playRequest
if (this.ctx.state !== 'running') await this.ctx.resume()
if (request !== this.playRequest || this.musicLoading || this.disposed) return
if (this.anchorPos >= this.duration - 0.05) this.anchorPos = this.minTime
this.playing = true
this.startSource()
}

pause() {
this.playRequest++
if (!this.playing) return
this.anchorPos = this.position()
this.playing = false
this.stopSource()
this.generation++
}

dispose() {
this.disposed = true
this.musicRequest++
this.pause()
void this.ctx.close()
}

toggle() {
return this.playing ? (this.pause(), Promise.resolve()) : this.play()
}
Expand Down
Loading
Loading