From dfb70d1d3e51ce59552d2f690d1ed2375a07808c Mon Sep 17 00:00:00 2001 From: Dannii Willis Date: Sat, 7 Dec 2024 14:23:39 +1000 Subject: [PATCH] Basic sound channel support! (no (un)pause yet) --- .github/workflows/test.yml | 2 +- src/blorb/blorb.ts | 2 +- src/common/protocol.ts | 4 +- src/glkote/common/glkote.ts | 7 +- src/glkote/web/schannels.ts | 162 ++++++++++++++++++++++++++++++++++++ src/glkote/web/web.ts | 8 ++ 6 files changed, 179 insertions(+), 6 deletions(-) create mode 100644 src/glkote/web/schannels.ts diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b4ebd4a..b672d4f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -6,7 +6,7 @@ jobs: test: strategy: matrix: - node: [18, 20, 22] + node: [18, 20, 22, 23] name: test (Node v${{ matrix.node }}) runs-on: ubuntu-latest steps: diff --git a/src/blorb/blorb.ts b/src/blorb/blorb.ts index 8e80777..923df47 100644 --- a/src/blorb/blorb.ts +++ b/src/blorb/blorb.ts @@ -228,7 +228,7 @@ export class Blorb { return null } - get_chunk(usage: string, num: number): BlorbChunk | null { + get_chunk(usage: BlorbChunk['usage'], num: number): BlorbChunk | null { return this.chunks[`${usage}:${num}`] || null } diff --git a/src/common/protocol.ts b/src/common/protocol.ts index a0cefe8..d3e42e5 100644 --- a/src/common/protocol.ts +++ b/src/common/protocol.ts @@ -464,9 +464,7 @@ export interface PlayOperation { /** Number of repeats (default: 1) */ repeats?: number, /** Sound resource ID (from a Blorb) */ - snd?: number, - /** Sound URL */ - url?: string, + snd: number, } export interface SetVolumeOperation { diff --git a/src/glkote/common/glkote.ts b/src/glkote/common/glkote.ts index 6da5405..63878eb 100644 --- a/src/glkote/common/glkote.ts +++ b/src/glkote/common/glkote.ts @@ -88,7 +88,7 @@ export abstract class GlkOteBase implements GlkOte { protected accept_func: (event: protocol.Event) => void = () => {} protected autorestoring = false - protected Blorb?: Blorb + Blorb?: Blorb current_metrics = Object.assign({}, Constants.DEFAULT_METRICS) protected Dialog?: Dialog disabled = false @@ -207,6 +207,9 @@ export abstract class GlkOteBase implements GlkOte { if (data.input) { this.update_inputs(data.input) } + if (data.schannels && this.Blorb) { + this.update_schannels(data.schannels) + } if (data.timer !== undefined) { if (this.timer) { @@ -330,6 +333,8 @@ export abstract class GlkOteBase implements GlkOte { protected set_page_bg(colour: string) {} + protected update_schannels(windows: protocol.SoundChannelUpdate[]) {} + // Functions to be implemented in a subclass protected abstract cancel_inputs(windows: protocol.InputUpdate[]): void protected abstract disable(disable: boolean): void diff --git a/src/glkote/web/schannels.ts b/src/glkote/web/schannels.ts new file mode 100644 index 0000000..17b7974 --- /dev/null +++ b/src/glkote/web/schannels.ts @@ -0,0 +1,162 @@ +/* + +Web GlkOte Sound Channels +========================= + +Copyright (c) 2024 Dannii Willis +MIT licenced +https://github.com/curiousdannii/asyncglk + +*/ + +import * as protocol from '../../common/protocol.js' +import WebGlkOte from './web.js' + +export class SoundChannelManager extends Map { + private context: AudioContext + private glkote: WebGlkOte + + constructor(glkote: WebGlkOte) { + super() + this.glkote = glkote + this.context = new AudioContext() + } + + update(schannels: protocol.SoundChannelUpdate[]) { + const wanted_schannels = [] + for (const schannel of schannels) { + const {id, ops} = schannel + wanted_schannels.push(id) + + // Add new channels + if (!this.has(id)) { + this.set(id, new SoundChannel(this.glkote, this.context)) + } + + // Do operations + if (ops) { + this.get(id)!.do_ops(ops) + } + } + + // Remove unwanted channels + for (const [id, schannel] of this) { + if (!wanted_schannels.includes(id)) { + schannel.delete() + this.delete(id) + } + } + } +} + +export class SoundChannel { + private context: AudioContext + private gain: GainNode + private glkote: WebGlkOte + private notify = 0 + private source: AudioBufferSourceNode | null = null + + constructor(glkote: WebGlkOte, context: AudioContext) { + this.context = context + this.glkote = glkote + this.gain = context.createGain() + this.gain.connect(context.destination) + } + + delete() { + this.gain.disconnect() + } + + async do_ops(ops: protocol.SoundChannelOperation[]) { + for (const op of ops) { + switch (op.op) { + case 'pause': + break + + case 'play': { + this.stop() + + // Get the data from Blorb + const chunk = this.glkote.Blorb!.get_chunk('sound', op.snd) + if (!chunk) { + continue + } + // Decode + const buffer = await this.context.decodeAudioData(chunk.content!.slice().buffer) + const source = this.context.createBufferSource() + source.buffer = buffer + + if (op.repeats && op.repeats !== 1) { + source.loop = true + if (op.repeats > 0) { + source.stop(this.context.currentTime + buffer.duration * op.repeats) + } + } + + if (op.notify) { + this.notify = op.notify + source.addEventListener('ended', this.on_stop) + } + + // Play! + source.connect(this.gain) + source.start() + this.source = source + + break + } + + case 'stop': + this.stop() + break + + case 'unpause': + break + + case 'volume':{ + const gain = this.gain.gain + const notify = () => { + this.glkote.send_event({ + type: 'volume', + notify: op.notify, + }) + } + + if (op.dur) { + const currentTime = this.context.currentTime + gain.setValueAtTime(gain.value || 0.0001, currentTime) + gain.exponentialRampToValueAtTime(op.vol || 0.0001, currentTime + (op.dur) / 1000) + if (op.notify) { + setTimeout(notify, op.dur) + } + } + else { + gain.value = op.vol + if (op.notify) { + notify() + } + } + break + } + } + } + } + + // Only for sound finished events, not volume + private on_stop = () => { + this.glkote.send_event({ + type: 'sound', + notify: this.notify, + }) + } + + private stop() { + const source = this.source + if (source) { + source.removeEventListener('ended', this.on_stop) + source.stop() + source.disconnect() + this.source = null + } + } +} \ No newline at end of file diff --git a/src/glkote/web/web.ts b/src/glkote/web/web.ts index c3d2c3a..d8c60bb 100644 --- a/src/glkote/web/web.ts +++ b/src/glkote/web/web.ts @@ -13,6 +13,7 @@ import * as GlkOte from '../common/glkote.js' import * as protocol from '../../common/protocol.js' import Metrics from './metrics.js' +import {SoundChannelManager} from './schannels.js' import {DOM, is_iOS} from './shared.js' import TranscriptRecorder from './transcript-recorder.js' import Windows, {GraphicsWindow} from './windows.js' @@ -46,6 +47,7 @@ export default class WebGlkOte extends GlkOte.GlkOteBase implements GlkOte.GlkOt windowport_id: 'windowport', }) metrics_calculator: Metrics + private schannels: SoundChannelManager private showing_error = false private showing_loading = true private transcript_recorder?: TranscriptRecorder @@ -55,6 +57,7 @@ export default class WebGlkOte extends GlkOte.GlkOteBase implements GlkOte.GlkOt super() this.metrics_calculator = new Metrics(this) + this.schannels = new SoundChannelManager(this) this.windows = new Windows(this) } @@ -180,6 +183,7 @@ export default class WebGlkOte extends GlkOte.GlkOteBase implements GlkOte.GlkOt 'graphics', 'graphicswin', 'hyperlinks', + 'sounds', 'timer', ] } @@ -358,6 +362,10 @@ export default class WebGlkOte extends GlkOte.GlkOteBase implements GlkOte.GlkOt this.windows.update_inputs(windows) } + protected update_schannels(schannels: protocol.SoundChannelUpdate[]) { + this.schannels.update(schannels) + } + protected update_windows(windows: protocol.WindowUpdate[]) { this.windows.update(windows) }